Nuxt devient Full-Stack : Comment gérer l'autorisation ?

- Read in English

Lorsque vous construisez une application, vous aurez besoin de permettre ou de restreindre l'accès à certaines parties ou à certaines données en fonction du rôle ou des permissions de l'utilisateur. C'est ce qu'on appelle l'autorisation.

Cette partie, dans toute application, est cruciale pour des raisons de sécurité. Vous ne voulez pas exposer des données sensibles, comme l'adresse e-mail ou le mot de passe d'un utilisateur, ou fuir des informations privées qui pourraient compromettre la confidentialité de vos utilisateurs et votre entreprise.

Juste un rappel : l'authentification concerne la vérification de l'identité d'un utilisateur, tandis que l'autorisation concerne l'octroi ou le refus d'accès à des ressources en fonction du rôle ou des permissions de l'utilisateur.

En construisant Orion, j'ai dû faire face à ce problème et j'ai essayé différentes approches. Dans cet article, je vais partager avec vous de nombreuses façons de gérer l'autorisation dans une application NuxtHub, de la manière la plus simple à la plus avancée. Il est important de noter que la manière la plus avancée n'est pas la meilleure pour toutes les applications.

Orion est une collection de modèles dirigée par la communauté pour votre prochain projet, des pages d'atterrissage aux applications web complètes. NuxtHub est une plateforme de déploiement et d'administration pour Nuxt, alimentée par Cloudflare.

Construire une application Nuxt Full-Stack sur Twitch

Le Contexte

Imaginez 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 appelant GET /api/publication/<id>. Cet endpoint renvoie une publication. À première vue, il n'y a pas de problème avec cet endpoint. Mais si vous regardez de plus près, vous verrez qu'une publication a un statut. Ce statut peut être brouillon, publié ou supprimé.

Le problème est que tout le monde peut accéder à cet endpoint et voir le contenu d'une publication brouillon ou d'une publication supprimée. Certains pourraient accéder au contenu d'une publication avant qu'elle ne soit publiée et selon le contenu, cela pourrait être des informations importantes comme un communiqué de presse ou un lancement de produit. Ce problème pourrait ruiner votre entreprise.

C'est un problème de sécurité et vous devez restreindre l'accès à cet endpoint en utilisant le principe du moindre privilège. La sécurité n'est pas une fonctionnalité, elle doit être conçue.

Authentifié ou Non

Toute cette partie est basée sur le fait que vous utilisez le package Nuxt nuxt-auth-utils. Mais en réalité, cela n'a pas d'importance.

La première étape pour protéger cet endpoint est de vérifier si l'utilisateur est authentifié. Si l'utilisateur n'est pas authentifié, je veux renvoyer une erreur 401 Non autorisé.

En plongeant dans le package nuxt-auth-utils, j'ai trouvé une utilité serveur appelée requireUserSession. Cette utilité peut être utilisée au début d'un endpoint pour essayer de récupérer la session de l'utilisateur et déclencher une erreur 401 Non autorisé s'il n'y a pas de session, c'est-à-dire que l'utilisateur n'est pas authentifié.

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

  const publication = {} // ...

  return publication
})

C'est une première étape pour protéger l'endpoint contre les utilisateurs non authentifiés. Mais si quiconque peut créer un compte, il peut accéder au contenu d'une publication brouillon. Je dois aller plus loin.

Seulement les Administrateurs

Sur la base de requireUserSession, je peux facilement créer une nouvelle utilité appelée requireAdminSession. Cette session va vérifier si l'utilisateur est authentifié et si l'utilisateur est un administrateur. Si l'utilisateur n'est pas un administrateur, je veux renvoyer une erreur 403 Interdit. Non autorisé vs Interdit : Non autorisé concerne l'authentification, Interdit 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
}

Ensuite, je peux remplacer requireUserSession par requireAdminSession dans l'endpoint.

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

  const publication = {} // ...

  return publication
})

Maintenant, seuls les administrateurs, c'est-à-dire les utilisateurs avec le rôle admin, peuvent accéder au contenu d'une publication.

Mais ce n'est pas vraiment mieux car si la publication est publiée, je veux permettre à tout le monde d'y accéder. En fait, seuls les administrateurs peuvent accéder au contenu d'une publication, quel que soit l'état de la publication.

Problèmes avec ces Approches

Avec les approches précédentes, il n'y a pas suffisamment de flexibilité pour avoir un système d'autorisation granulaire. Je ne peux pas gérer le cas où l'auteur de la publication peut accéder au contenu de la publication, même si celle-ci n'est pas publiée.

Pour résoudre cela, je pourrais écrire une autre utilité appelée 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',
  })
}

C'est acceptable jusqu'à ce que je doive gérer plus d'endpoints. Cette approche va générer beaucoup de code dupliqué sur la gestion des erreurs. Ce code est également plus difficile à tester parce que vous devez gérer un événement H3 complet.

Sans une meilleure approche, je continue de développer Orion, mais rapidement, je rencontre un autre problème : je dois gérer la même logique d'autorisation côté client. Je ne veux pas montrer un bouton d'édition à un utilisateur qui n'est pas l'auteur de la publication ou un administrateur, et je ne veux pas cacher ce bouton d'édition de l'administrateur si je décide de lui permettre d'éditer la publication sur le serveur. Comme pour la validation des formulaires, la duplication de logique peut entraîner beaucoup d'incohérences entre le client et le serveur et de la frustration pour les clients. Les utilitaires que j'ai créés jusqu'à présent ne sont que pour le côté serveur à cause de l'utilisation de requireUserSession et getUserSession.

Faisons une pause pour comprendre comment ce problème pourrait être résolu. L'autorisation est trois choses :

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

Les deux premières sont des conditions simples qui retournent un booléen. Elles répondent à des questions comme "Puis-je accéder à cette publication ? Oui ou Non". Ensuite, le développeur doit gérer cette réponse manuellement. La troisième donne accès à la ressource. À la question "Puis-je accéder à cette publication ?", il n'y a pas de "oui" ou "non", mais rien ne se passe si c'est un "oui", et une erreur est lancée si c'est un "non". L'autoriser envoie une erreur automatiquement.

Tout cela n'est pas lié au client ou au serveur et encore moins au framework que vous utilisez ou au système d'authentification. Dans cet esprit, j'ai commencé à travailler sur un module local pour Orion afin de gérer l'autorisation de manière plus flexible. Après quelques jours de travail, je suis fier de présenter nuxt-authorization.

À l'origine, je voulais créer un package Nitro et un package Nuxt, mais les modules Nitro ne sont pas prêts. Cependant, j'ai créé quelques PRs dans le 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 est une manière simple mais puissante de gérer l'autorisation dans une application Nuxt, tant côté client que côté serveur. Il est indépendant du système d'authentification mais peut facilement être utilisé avec nuxt-auth-utils.

Pour apprendre à l'utiliser, consultez le dépôt GitHub : nuxt-authorization. Si vous souhaitez en savoir plus sur la conception et l'implémentation, continuez à lire.

Le module est encore à un stade précoce de développement et si vous avez des retours ou des idées pour l'améliorer, 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 d'expliquer ce que j'ai réalisé avec nuxt-authorization, explorons les problèmes que je voulais résoudre.

Tout comme pour la validation des formulaires, je veux avoir à la fois une seule source de vérité et une seule API tant sur l'application que sur le serveur. Cela est vraiment important pour éviter les incohérences entre l'application et le serveur.

Je veux également pouvoir définir la logique d'autorisation de manière simple et regroupée par ressource. Je pense que c'est plus facile à lire, à maintenir, et à maintenir la cohérence entre les différentes parties de l'application, comme la validation des formulaires.

La logique d'autorisation doit être suffisamment flexible pour traiter un large éventail de cas d'utilisation, d'une condition permettre ou refuser à une condition autoriser plus complexe. L'erreur lancée par la condition autoriser doit être personnalisable pour fournir une meilleure expérience utilisateur. Toutes les erreurs non autorisées ne doivent pas être un 403 Interdit.

Enfin, je veux avoir des composants pour faciliter l'intégration de la logique d'autorisation dans les modèles. Je veux pouvoir afficher ou masquer un bouton en fonction de la logique d'autorisation, par exemple.

Conception

Le module est composé de 2 parties : l'habilité et le bouncer.

Les habilités sont les règles qui définissent la logique d'autorisation. Elles prennent un utilisateur et une ou plusieurs ressources et retournent une condition de refus ou d'autorisation. Elles peuvent être regroupées par ressource mais restent indépendantes les unes des autres.

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

Dans cet exemple, j'autorise seulement l'auteur du livre à l'éditer. Par défaut, les habilités ne sont exécutées que si l'utilisateur est authentifié mais il est possible de permettre aux invités d'accéder à certaines ressources.

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

Avec cela, j'autorise tout le monde à lister les livres, même les utilisateurs non authentifiés.

La fonction defineAbility est une usine qui crée une habilité mais rien de plus. Je dois l'utiliser avec des fonctions de bouncer.

Dans un endpoint serveur, je peux utiliser la fonction autoriser pour autoriser l'accès à une ressource en fonction des habilités.

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

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

  return books
})

La fonction authorize lancera une erreur 403 Interdit si l'utilisateur n'est pas autorisé à lister les livres. Vous pouvez avoir un contrôle plus granulaire 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 lancer une erreur, je décide de retourner un tableau vide si l'utilisateur n'est pas autorisé à lister les livres. Votre logique métier déterminera la meilleure façon de gérer l'accès non autorisé.

Par exemple, vous pourriez retourner une erreur 404 Non trouvé si l'utilisateur n'est pas autorisé à accéder à une ressource spécifique pour préserver la confidentialité de vos données. Savoir que la ressource existe pourrait poser un problème de sécurité.

Cette personnalisation est possible au sein des habilités grâce aux 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()
})

Maintenant, la fonction authorize lancera une erreur 404 Non trouvé si l'utilisateur n'est pas autorisé à voir un livre brouillon au lieu du 403 Interdit par défaut.

Côté client, je peux également utiliser les fonctions allows, denies et authorize pour gérer la logique d'autorisation.

En plus de ces fonctions, le module fournit deux composants : Can et Cannot. Ces composants vous permettent d'afficher ou de masquer une partie du modèle en fonction de la logique d'autorisation.

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

En savoir plus sur le dépôt GitHub : nuxt-authorization.

Dernières Pensées

Ce package, tant le code que la conception, s'inspire fortement de Adonis Bouncer. C'est un package bien écrit et je pense que réinventer la roue à chaque fois est inutile.

J'utilise déjà ce package dans Orion et c'est vraiment agréable à utiliser. Je suis sûr qu'il y a encore de la place pour l'amélioration, mais ce module est un premier pas pour donner plus de pouvoir aux développeurs Nuxt et pour pousser la partie full-stack de Nuxt. N'hésitez pas à contribuer et à suggérer de nouvelles fonctionnalités ou améliorations.

Orion est une collection de modèles dirigée par la communauté pour votre prochain projet, des pages d'atterrissage aux applications web complètes.

J'espère que cet article et le module vous aideront à gérer l'autorisation dans votre application Nuxt pour prévenir et restreindre l'accès non autorisé à vos données. La sécurité n'est pas une fonctionnalité.

Bon codage !

Retour aux articles
Soutenez mon travail
Suivez-moi sur