Nuxt Devient Full-Stack : Comment Gérer l'Autorisation ?

- Read in english

Lorsque vous développez une application, il est essentiel de granter ou de restrict l'accès à des zones ou des données spécifiques en fonction du rôle ou des permissions d'un utilisateur. Ce concept est connu sous le nom d'autorisation.

L'autorisation est un aspect critique de toute application à des fins de sécurité. Il est impératif de protéger les données sensibles, telles que l'adresse e-mail ou le mot de passe d'un utilisateur, et de prévenir la divulgation d'informations privées qui pourraient compromettre la vie privée des utilisateurs et l'intégrité de votre entreprise.

Pour clarifier : l'authentification implique de vérifier l'identité d'un utilisateur, tandis que l'autorisation consiste à permettre ou à refuser l'accès à des ressources en fonction du rôle ou des permissions de l'utilisateur.

En construisant Orion, j'ai été confronté à ce défi et j'ai expérimenté plusieurs approches. Dans cet article, je partagerai plusieurs stratégies pour gérer l'autorisation dans une application NuxtHub, allant des méthodes les plus simples aux plus sophistiquées. Il est crucial de comprendre que la méthode la plus complexe n'est pas nécessairement la meilleure pour chaque application.

Orion sert de collection de templates dirigée par la communauté pour vos projets, allant des pages d'atterrissage aux applications web complètes. NuxtHub fonctionne comme une plateforme de déploiement et de gestion pour Nuxt, propulsée par Cloudflare.

Créer une App Nuxt Full-Stack : une Aventure sur Twitch

Le Contexte

Considérez cet endpoint dans votre application Nuxt :

ts
export default defineEventHandler(async (event) => {
  const id = getRouteParam(event, 'id')

  const publication = await db.query.publications.findOne({
    where: { id },
    columns: {
      title: true,
      content: true,
      status: true,
      authorId: true,
    },
  })

  return publication
})

Vous pouvez accéder à cet endpoint en effectuant une requête GET /api/publication/<id>. Cet endpoint retourne une publication. À première vue, il n'y a pas de problème avec cet endpoint. Cependant, un examen plus attentif révèle qu'une publication a un statut qui peut être brouillon, publiée ou supprimée.

Le problème est que quiconque peut accéder à cet endpoint et consulter une publication brouillon ou une publication supprimée. Certains pourraient être en mesure de voir le contenu d'une publication avant sa publication, ce qui pourrait contenir des informations vitales telles qu'un communiqué de presse ou le lancement d'un produit. Un tel scénario pourrait nuire considérablement à votre entreprise.

Il s'agit d'un problème de sécurité qui nécessite de restreindre l'accès à cet endpoint, en utilisant le principe du moindre privilège. La sécurité n'est pas simplement une fonctionnalité mais doit être intégrée dans la conception du système.

Authentifié ou Non

La section suivante repose sur l'utilisation du package Nuxt nuxt-auth-utils. Néanmoins, son utilisation n'est pas essentielle.

La première étape pour sécuriser cet endpoint consiste à vérifier si l'utilisateur est authentifié. Si l'utilisateur ne dispose pas d'une authentification, il convient de renvoyer une erreur 401 Unauthorized.

En explorant le package nuxt-auth-utils, j'ai découvert un utilitaire serveur appelé requireUserSession. Cet utilitaire peut être utilisé au début d'un endpoint pour tenter de récupérer la session de l'utilisateur et déclencher une erreur 401 Unauthorized s'il n'y a pas de session, indiquant que l'utilisateur n'est pas authentifié.

ts
export default defineEventHandler(async (event) => {
  await requireUserSession(event)

  const publication = {} // ...

  return publication
})

C'est la première étape pour protéger l'endpoint contre les utilisateurs non authentifiés. Cependant, si quiconque peut s'inscrire pour créer un compte, il pourrait toujours accéder au contenu d'une publication brouillon. D'autres actions sont donc nécessaires.

Seulement pour les Administrateurs

En s'appuyant sur requireUserSession, je peux facilement concevoir un nouvel utilitaire nommé requireAdminSession. Cette session vérifiera si l'utilisateur est authentifié et dispose de droits d'administrateur. Si l'utilisateur n'est pas un administrateur, une erreur 403 Forbidden doit être renvoyée. Non autorisé se rapporte à l'authentification, tandis que Forbidden concerne l'autorisation.

ts
export async function requireAdminSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
  const userSession = await requireUserSession(event)
  const { user } = userSession

  if (user.roleType !== 'admin') {
    throw createError({
      statusCode: opts.statusCode || 403,
      message: opts.message || 'Non autorisé',
    })
  }

  return userSession as UserSessionRequired
}

Par la suite, requireUserSession peut être remplacé par requireAdminSession dans l'endpoint.

ts
export default defineEventHandler(async (event) => {
  await requireAdminSession(event)

  const publication = {} // ...

  return publication
})

Maintenant, seuls les admins, les utilisateurs ayant le rôle admin, peuvent accéder au contenu d'une publication.

Cependant, cette approche n'est pas entièrement efficace, car si la publication est publiée, je souhaite permettre à tous les utilisateurs d'y accéder. Actuellement, seuls les admins peuvent voir le contenu d'une publication, quelle que soit le statut de la publication.

Problèmes avec ces Approches

Les stratégies mentionnées manquent de flexibilité suffisante pour un système d'autorisation détaillé. Elles ne peuvent pas accommoder les situations où l'auteur de la publication devrait avoir accès, même si la publication est non publiée.

Pour y remédier, je pourrais rédiger un autre utilitaire nommé requirePublicationAccess :

ts
export async function requirePublicationAccess(event: H3Event, publication: Publication, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
  const userSession = await getUserSession(event)
  const { user } = userSession

  if (publication.status === 'published')
    return userSession

  if (!userSession) {
    throw createError({
      statusCode: opts.statusCode || 401,
      message: opts.message || 'Non autorisé',
    })
  }

  if (publication.authorId === user.id)
    return userSession

  if (user.roleType === 'admin')
    return userSession

  throw createError({
    statusCode: opts.statusCode || 403,
    message: opts.message || 'Interdit',
  })
}

Cette solution est adéquate tant que je dois gérer d'autres endpoints. Cette approche entraînera une excessive duplication de code concernant le traitement des erreurs. De plus, le code devient plus difficile à tester en raison de la nécessité de gérer un événement H3 entier.

Sans une meilleure approche, je continue à développer Orion mais me heurte bientôt à un autre défi : je dois mettre en œuvre la même logique d'autorisation côté client. Je ne souhaite pas afficher un bouton d'édition à un utilisateur qui n'est ni l'auteur de la publication ni un administrateur, et je ne veux pas cacher ce bouton d'édition à l'administrateur si je décide de permettre l'édition de la publication sur le serveur. Comme pour la validation des formulaires, la duplication de la logique peut entraîner des incohérences substantielles entre le client et le serveur, menant à la frustration des clients. Les utilitaires que j'ai créés jusqu'à présent sont uniquement côté serveur en raison de l'utilisation de requireUserSession et getUserSession.

Faisons un pas en arrière pour comprendre comment ce problème pourrait être résolu. L'autorisation se compose de trois composants :

  • permettre l'accès à une ressource
  • refuser l'accès à une ressource
  • autoriser l'accès à une ressource

Les deux premiers sont des conditions simples produisant une réponse booléenne à des questions comme "Puis-je accéder à cette publication ? Oui ou Non". Le développeur doit gérer manuellement cette réponse. Le troisième accorde l'accès à la ressource. Pour la question "Puis-je accéder à cette publication ?", il n'y a ni "oui" ni "non" ; rien ne se passe si la réponse est "oui", mais une erreur est levée si la réponse est "non". L'autoriser envoie automatiquement une erreur.

Ces éléments n'ont aucun lien direct avec le client, le serveur, ou le framework utilisé ou le système d'authentification. Munis de cette connaissance, j'ai commencé à travailler sur un module local pour Orion afin de gérer l'autorisation de manière plus flexible. Après plusieurs jours d'efforts, je suis fier de vous présenter nuxt-authorization.

Au départ, je visais à développer à la fois un module Nitro et un module Nuxt, mais les modules Nitro ne sont pas encore prêts. Cela dit, j'ai soumis quelques demandes de tirage au dépôt Nitro pour améliorer le système de modules. L'une d'elles est le guide de l'auteur du module.

Présentation de nuxt-authorization

Ce module offre une méthode simple mais puissante pour gérer l'autorisation dans une application Nuxt, applicable tant au client qu'au serveur. Il est indépendant du système d'authentification mais peut être intégré sans effort avec nuxt-auth-utils.

Pour des instructions sur son utilisation, visitez le dépôt GitHub : nuxt-authorization. Pour explorer la conception et l'implémentation, continuez à lire.

Le module est encore à ses débuts de développement, et si vous avez des retours ou des suggestions d'amélioration, n'hésitez pas à ouvrir un problème sur le dépôt GitHub ou à me contacter sur X.

Problèmes que je souhaite résoudre

Avant de décrire ce qui a été accompli avec nuxt-authorization, examinons les problèmes que je souhaitais résoudre.

Tout comme la validation des formulaires, je vise à maintenir une source de vérité unique et une API unique tant pour l'application que pour le serveur. Cela est crucial pour éviter les divergences entre l'application et le serveur.

Je souhaite également avoir la possibilité de définir la logique d'autorisation de manière simple et regroupée par ressource. Cette approche améliore la lisibilité, la maintenabilité et la cohérence à travers divers aspects de l'application, tels que la validation des formulaires.

La logique d'autorisation doit être suffisamment flexible pour accommoder une gamme variée de cas d'utilisation, des conditions permettre ou refuser aux conditions autoriser plus complexes. Les erreurs produites par la condition autoriser devraient être personnalisables pour améliorer l'expérience utilisateur. Toutes les erreurs non autorisées ne doivent pas nécessairement être un 403 Forbidden.

Enfin, je désire des composants qui facilitent l'intégration de la logique d'autorisation dans les templates. Par exemple, je souhaite avoir la capacité d'afficher ou de cacher un bouton en fonction de la logique d'autorisation.

Conception

Le module se compose de deux composants : l'habilité et le bouncer.

Les habilités constituent les règles qui incarnent la logique d'autorisation. Elles prennent un utilisateur et une ou plusieurs ressources et génèrent une condition de refus ou d'autorisation. Bien qu'elles puissent être regroupées par ressource, elles restent indépendantes les unes des autres.

ts
export const editBook = defineAbility((user: User, book: Book) => {
  return user.id === book.authorId
})

Dans l'exemple ci-dessus, seul l'auteur d'un livre est autorisé à le modifier. Par défaut, les aptitudes ne sont exécutées que si l'utilisateur est authentifié, mais il est possible de permettre un accès invité à certaines ressources.

ts
export const listBooks = defineAbility((user: User | null) => {
  return true
})

Dans cet exemple, tout le monde, y compris les utilisateurs non authentifiés, est autorisé à lister les livres.

La fonction defineAbility agit comme une usine qui crée une aptitude mais rien au-delà de cela. Elle doit être utilisée avec des fonctions bouncer.

Dans un endpoint serveur, la fonction autoriser peut être utilisée pour accorder l'accès à une ressource basée sur les aptitudes.

ts
export default defineEventHandler(async (event) => {
  await authorize(event, listBooks)

  const books = await db.query.books.findMany()

  return books
})

La fonction autoriser déclenchera une erreur 403 Forbidden si l'utilisateur n'est pas autorisé à lister les livres. Un contrôle plus granulaire peut être obtenu en utilisant les fonctions allows ou denies.

ts
export default defineEventHandler(async (event) => {
  if (await denies(event, listBooks, book))
    return []

  const books = await db.query.books.findMany()

  return books
})

Au lieu de déclencher une erreur, un tableau vide est retourné si l'utilisateur n'est pas autorisé à lister les livres. La méthode appropriée pour gérer l'accès non autorisé dépendra de votre logique commerciale.

Par exemple, retourner une erreur 404 Not Found si l'utilisateur n'est pas autorisé à accéder à une ressource spécifique peut préserver la confidentialité des données. Divulguer l'existence de la ressource pourrait poser un risque pour la sécurité.

Cette personnalisation est atteignable dans les aptitudes via les fonctions allow et deny.

ts
export const viewBook = defineAbility((user: User, book: Book) => {
  if (book.status === 'draft') {
    return deny({
      statusCode: 404,
      message: 'Non trouvé',
    })
  }

  return allow()
})

Désormais, la fonction autoriser lancera une erreur 404 Not Found si l'utilisateur n'est pas autorisé à voir un livre en brouillon, au lieu du 403 Forbidden par défaut.

Côté client, les fonctions allows, denies et authorize peuvent également être utilisées pour administrer la logique d'autorisation.

De plus, ces fonctions offrent deux composants : Can et Cannot. Ces composants permettent d'afficher ou de cacher des portions d'un template en fonction de la logique d'autorisation.

vue
<template>
  <Can :ability="editBook" :args="[book]">
    <button>Modifier</button>
  </Can>
</template>

Consultez le dépôt GitHub pour plus d'informations : nuxt-authorization.

Dernières Pensées

Le code et la conception de ce package s'inspirent fortement du Adonis Bouncer. C'est un produit de qualité, et réinventer des concepts inutilement est souvent futile.

J'implémente déjà ce package dans Orion, et l'expérience a été très agréable. Je suis convaincu qu'il y a encore un potentiel d'amélioration, mais ce module représente un premier pas pour autonomiser les développeurs Nuxt et faire progresser les capacités full-stack de Nuxt. Les contributions et suggestions pour de nouvelles fonctionnalités ou améliorations sont les bienvenues.

Orion est une collection de templates dirigée par la communauté pour vos projets, s'étendant des pages d'atterrissage aux applications web complètes.

J'espère que cet article et ce module vous aideront à gérer l'autorisation au sein de votre application Nuxt, empêchant ainsi et restreignant l'accès non autorisé à vos données. La sécurité est impérative.

Bonne programmation !

Photo de profil d'Estéban

Merci de me lire ! Je m'appelle Estéban, et j'adore écrire sur le développement web.

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 !

Soutenir mon travail
Suivez-moi sur