Sécuriser du contenu statique payant dans Next.js (avec l'App Router)

|

Cover image

Un article de blog si bon que les gens sont prêts à payer pour le lire

Lorsque l'on parle de modèles architecturaux avancés, il est très important d'avoir un cas d'utilisation concret en tête.

Imaginez que vous gériez un blog pour développeurs et que vous ayez passé un temps incroyable à écrire votre dernier article, "Remix c'est mieux que Next.js. Peut-être. Je sais pas.".

Cet article est si bon, si original, si controversé, qu'il serait dommage de le partager gratuitement. Ce contenu exceptionnel mérite vraiment d'être payant.

Apprenons comment mettre en place un paywall dans Next.js.

Nous allons découvrir :

  • une façon incorrecte de vérifier l'authentification et le paiement (via les layouts)
  • une façon correcte mais sous-optimale (vérification dynamique)
  • une façon plus efficace avec du rendu statique (vérification en amont dans un serveur proxy)

Nous n'aborderons pas la logique d'authentification et de vérification de paiement en elle-même, mais plutôt comment utiliser cette logique efficacement. Dans cet article, je suppose que vous stockez la liste des utilisateurs payants dans une base de données, ou que vous utilisez un service tiers comme Stripe.

Si vous voulez lire sur l'authentification dans Next.js App Router, vous pouvez consulter la vidéo de Lee Robinson sur le sujet.

Commençons par la première approche : celle qui est très tentante mais qui ne fonctionne pas réellement !

🇬🇧 Speak English? Find the original version of this article on my personal blog

La mauvaise façon : l'authentification dans le layout

Dans Express, vous pouvez protéger toutes les routes d'une application en une seule ligne de code :

// vérification de l'authentification et du paiement pour toutes les routes
app.use((req, res, next) => {
  if (!hasPaid(req)) {
    return res.redirect('/subscribe')
  }
  next()
})
// seuls les utilisateurs payants peuvent accéder à cet endpoint
app.get('/paid-article', (req, res) => {
  return res.render('paid-article.html')
})

Certains développeurs n'aiment pas beaucoup ce pattern, car il peut impliquer de reconstruire toute la logique de routing dans le app.use global, alors qu'un peu de répétition de code serait plus efficace. Cependant il est relativement pratique pour des sites simples dont tout le contenu est payant.

Notre objectif est d'implémenter la même chose, mais dans Next.js.

Il est très tentant d'utiliser un layout pour la vérification d'authentification. Étant donné qu'un layout est partagé par plusieurs pages, cela serait une façon efficace de sécuriser ces pages avec une seule ligne de code dans le layout, n'est-ce pas ?

// app/layout.jsx
async function checkPaid() {
  const token = cookies.get("auth_token");
  return await db.hasPayments(token);
}
export default async function Layout() {
  // ❌ cela ne fonctionnera pas comme prévu !!
  const hasPaid = await checkPaid();
  if (!hasPaid) redirect("/subscribe");
  // puis rendre la page sous-jacente
  return <div>{children}</div>;
}
// app/page.tsx
// ❌ cette page est accessible directement !
export default async function Page() {
  const content = await getContent()
  return <div>{content}</div>
}

Malheureusement, cette méthode ne fonctionne pas comme prévu.

Les layouts ne sont pas équivalents aux middlewares de haut niveau : il n'y a pas de garantie stricte qu'un layout soit rendu avant une page.

Les layouts et les pages ne forment PAS une chaîne de middlewares comme dans Express !

Lors de la navigation dans l'application, vous ne verrez pas le problème, car le layout sera rendu avant la page. Cependant, il est très facile d'envoyer une requête pour forcer le serveur Next à fournir la charge utile RSC de la page sans exécuter le layout en premier.

J'ai créé une reproduction open source de cette erreur : eric-burel/securing-rsc-layout-leak

Accès à une page privée via Insomnia Voici comment nous pouvons accéder au contenu payant, en utilisant Insomnia.

Je remercie l'utilisateur de Reddit qui m'a expliqué ce problème.

Ceci n'est pas un problème de sécurité dans Next.js. Il n'a jamais été annoncé que les layouts étaient l'endroit approprié pour effectuer l'authentification. Mais comme il est assez facile de confondre les layouts avec un genre de middleware générique, je voulais souligner cette erreur potentielle.

La méthode dynamique : vérification de l'authentification là où vous récupérez les données

Nous avons commencé par une approche invalide. Découvrons maintenant une approche dynamique qui fonctionne comme prévu.

Au lieu d'authentifier les utilisateurs le plus tôt possible, nous le faisons le plus tard possible. L'idée est d'effectuer la vérification d'accès lorsque nous récupérons les données.

// Vérification du paiement
async function checkPaid() {
  const token = cookies.get('auth_token')
  return await db.hasPayments(token)
}
// Récupération des données
async function getContent() {
  // ✔️ Bien, nous vérifions l'authentification lorsque nous obtenons les données
  const hasPaid = await checkPaid()
  if (!hasPaid) return null
  return await db.getArticle()
}
// Bonne pratique :
// utiliser un préfixe pour savoir que
// 1) cette fonction est dédupliquée
// 2) elle est censée gérer sa propre sécurité
// Cela facilite l'audit de sécurité
const rscGetContent = cache(getContent)
// app/page.tsx
export default async function Page() {
  const content = await rscGetContent()
  return <div>{content}</div>
}

Parfait, cela fonctionne comme prévu. Effectuer la vérification d'authentification dans la page aurait également fonctionné, mais déplacer la vérification plus près des données est beaucoup plus facile à lire.

Le problème est qu'une vérification dynamique implique aussi un rendu dynamique, à chaque fois qu'un utilisateur accède à la page.

Vous pouvez atténuer ce problème en configurant un cache partagé entre les utilisateurs.

Cela peut être fait :

  • en utilisant une solution tierce comme node-cache
  • en utilisant le unstable_cache intégré de Next.js, avec l'avantage que vous pourrez également utiliser les fonctionnalités de révalidation intégrées

Cela réduira le nombre d'appels à votre source de données, toutefois, vous devrez toujours rendre la page pour chaque requête.

La dernière approche que nous allons découvrir est compatible avec le rendu statique.

Contrairement au cache de React, le unstable_cache de Next.js est partagé entre les utilisateurs. Ils n'ont rien à voir entre eux !! Si vous mettez des données spécifiques à l'utilisateur dans unstable_cache, faites attention à utiliser les bonnes clés de cache et à ne pas divulguer de données !

La méthode statique : vérification de l'authentification en amont dans un middleware

La vérification d'authentification dynamique a fonctionné, mais elle était sous-optimale pour notre cas d'utilisation spécifique.

Étant donné que le contenu payant est le même pour tous les utilisateurs, il n'est pas judicieux de re-rendre la page à chaque requête simplement parce que nous devons lire les cookies pour reconnaître l'utilisateur.

Transformons notre page privée en une page totalement statique, mais toujours sécurisée ! Oui, ce n'est pas incompatible.

Voici les deux étapes pour y parvenir :

  1. Supprimez toutes les vérifications d'authentification de vos pages Next.js et des méthodes de récupération de données. Faites-moi confiance.
  2. Implémentez l'authentification dans un middleware Edge. Et voilà !

Désormais, vos pages peuvent rester statiques. L'authentification est effectuée en amont, avant même que les requêtes n'atteignent votre application Next.js.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { decrypt } from './your-auth-system'

export function middleware(req: NextRequest) {
  // Dans cet exemple
  // Je stocke le statut de paiement directement dans le jeton de session
  // Vous pouvez également appeler une API comme Stripe
  const sessionToken = req.cookies().get('session')?.value
  const session = decrypt(session)
  if (!session?.isPaid) {
    return NextResponse.redirect('/subscribe', new URL(req.url))
  }
  return NextResponse.next()
}

export const config = {
  matcher: '/((?!subscribe|_next/static|_next/image|favicon.ico).*)',
}

Le contenu payant est stocké dans le cache côté serveur de Next.js, et seuls les utilisateurs payants peuvent y accéder. L'avantage, c'est que maintenant, les performances sont optimales, grâce au rendu statique.

Limites d'un middleware

D'abord si vous n'êtes pas à l'aise avec l'utilisation des middlewares Next.js pour la vérification d'authentification, vous pouvez configurer n'importe quel autre type de serveur proxy.

Un serveur personnalisé ferait également l'affaire.

La seule contrainte est que ce serveur devrait être aussi rapide que possible. Il peut rester très simple puisque son seul rôle est de vérifier l'authentification et le paiement et d'effectuer une redirection si nécessaire.

Concernant les middlewares Edge, ils ont l'avantage d'être rapides. Cependant ils ne peuvent pas établir de connexions de base de données en utilisant TCP. Il faut donc plutôt utiliser le protocole HTTP, en appelant une API tierce ou en créant une surcouche autour de votre base de données, ce que font certains hébergeurs cloud comme Upstash.

Un dernier problème est la latence d'accès aux données. Vercel a annoncé revenir en arrière sur le rendu "at-the-edge", le problème est qu'un serveur Edge proche de l'utilisateur, risque d'être par contre éloigné de la base de données, les appels sont alors plus lents que si l'on faisait un rendu dynamique.

Dans notre cas, une solution peut consister à stocker l'information de paiement dans un cookie chiffré (typiquement un JWT), ce qui évite un appel à une API ou une base de données car il suffit de lire la requête HTTP.

Bref, l'optimisation ce n'est jamais aussi simple qu'il n'y paraît. Prenez le temps de mesurer les performances de votre site pour prendre la bonne décision !

Conclusion

Nous avons appris comment sécuriser l'accès à un blog payant, tout en gardant le contenu statique et donc très rapide à charger.

Mais ce n'est pas le seul cas d'utilisation. Cet article est en fait une suite de mon précédent article publié sur le Rendu Segmenté (Segmented Rendering en anglais).

Le Rendu Segmenté est une méthode pour implémenter la personnalisation statique en utilisant une redirection ou une réécriture d'URL. Vous pouvez réutiliser cette astuce pour implémenter de nombreux autres modèles tels que les tests A/B, les fonctionnalités à activer/désactiver (feature flagging en anglais). Cela fonctionne même pour les modèles avec des contraintes de sécurité moindres, comme l'internationalisation.

Par rapport au Pages Router, l'App Router apporte :

  • Des layouts, mais nous ne devrions pas les utiliser pour l'authentification de toute façon
  • Des React Server Components qui ne nécessitent pas d'hydratation côté client, ils sont donc plus performants lorsqu'il s'agit d'afficher le texte statique d'un article

Grâce au Rendu Segmenté et aux RSC, nous pouvons atteindre les meilleures performances possibles pour un blog payant !

Vous voulez en apprendre plus sur la sécurité ? Rejoignez la liste d'attente pour mon prochain cours, Sécuriser les applications full-stack Next.js.