Aller au contenu

d

Jeton d'authentification

Les utilisateurs doivent pouvoir se connecter à notre application, et lorsque l'utilisateur est connecté, ses informations doivent automatiquement être attachées à toutes les nouvelles notes qu'ils créent.

Nous allons maintenant implémenter le support de l'authentification basée sur des jetons dans le backend.

Les principes de l'authentification basée sur des jetons sont représentés dans le diagramme de séquence suivant :

diagramme de séquence de l'authentification basée sur des jetons
  • L'utilisateur commence par se connecter à l'aide d'un formulaire de connexion implémenté avec React

    • Nous ajouterons le formulaire de connexion à l'interface utilisateur dans la partie 5
  • Cela amène le code React à envoyer le nom d'utilisateur et le mot de passe à l'adresse du serveur /api/login sous forme de requête HTTP POST.
  • Si le nom d'utilisateur et le mot de passe sont corrects, le serveur génère un jeton qui identifie d'une certaine manière l'utilisateur connecté.

    • Le jeton est signé numériquement, le rendant impossible à falsifier (par des moyens cryptographiques)
  • Le backend répond avec un code d'état indiquant que l'opération a réussi et retourne le jeton avec la réponse.
  • Le navigateur enregistre le jeton, par exemple dans l'état d'une application React.
  • Lorsque l'utilisateur crée une nouvelle note (ou effectue une autre opération nécessitant une identification), le code React envoie le jeton au serveur avec la requête.
  • Le serveur utilise le jeton pour identifier l'utilisateur

Implémentons d'abord la fonctionnalité de connexion. Installez la bibliothèque jsonwebtoken, qui nous permet de générer des jetons web JSON.

npm install jsonwebtoken

Le code pour la fonctionnalité de connexion est placé dans le fichier controllers/login.js.

const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
const loginRouter = require('express').Router()
const User = require('../models/user')

loginRouter.post('/', async (request, response) => {
  const { username, password } = request.body

  const user = await User.findOne({ username })
  const passwordCorrect = user === null
    ? false
    : await bcrypt.compare(password, user.passwordHash)

  if (!(user && passwordCorrect)) {
    return response.status(401).json({
      error: 'invalid username or password'
    })
  }

  const userForToken = {
    username: user.username,
    id: user._id,
  }

  const token = jwt.sign(userForToken, process.env.SECRET)

  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

module.exports = loginRouter

Le code commence par rechercher l'utilisateur dans la base de données en utilisant le nom d'utilisateur joint à la requête.

const user = await User.findOne({ username })

Ensuite, il vérifie le mot de passe, également joint à la requête.

const passwordCorrect = user === null
  ? false
  : await bcrypt.compare(password, user.passwordHash)

Comme les mots de passe eux-mêmes ne sont pas enregistrés dans la base de données, mais plutôt des hashes calculés à partir des mots de passe, la méthode bcrypt.compare est utilisée pour vérifier si le mot de passe est correct:

await bcrypt.compare(password, user.passwordHash)

Si l'utilisateur n'est pas trouvé ou si le mot de passe est incorrect, la requête reçoit une réponse avec le code d'état 401 non autorisé. La raison de l'échec est expliquée dans le corps de la réponse.

if (!(user && passwordCorrect)) {
  return response.status(401).json({
    error: 'invalid username or password'
  })
}

Si le mot de passe est correct, un jeton est créé avec la méthode jwt.sign. Le jeton contient le nom d'utilisateur et l'identifiant de l'utilisateur sous une forme numériquement signée.

const userForToken = {
  username: user.username,
  id: user._id,
}

const token = jwt.sign(userForToken, process.env.SECRET)

Le jeton a été signé numériquement en utilisant une chaîne de la variable d'environnement SECRET comme secret. La signature numérique garantit que seules les parties qui connaissent le secret peuvent générer un jeton valide. La valeur de la variable d'environnement doit être définie dans le fichier .env.

Une requête réussie reçoit une réponse avec le code d'état 200 OK. Le jeton généré et le nom d'utilisateur de l'utilisateur sont renvoyés dans le corps de la réponse.

response
  .status(200)
  .send({ token, username: user.username, name: user.name })

Il ne reste plus qu'à ajouter le code pour la connexion à l'application en ajoutant le nouveau routeur à app.js.

const loginRouter = require('./controllers/login')

//...

app.use('/api/login', loginRouter)

Essayons de nous connecter en utilisant le client REST de VS Code:

post de vscode rest avec nom d'utilisateur/mot de passe

Cela ne fonctionne pas. Le message suivant est imprimé dans la console:

(node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value
    at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20)
    at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21)
(node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2)

La commande jwt.sign(userForToken, process.env.SECRET) échoue. Nous avons oublié de définir une valeur pour la variable d'environnement SECRET. Cela peut être n'importe quelle chaîne. Lorsque nous définissons la valeur dans le fichier .env (et redémarrons le serveur), la connexion fonctionne.

Une connexion réussie renvoie les détails de l'utilisateur et le jeton:

réponse du client rest de VS Code montrant les détails et le jeton

Un nom d'utilisateur ou un mot de passe incorrect renvoie un message d'erreur et le code d'état approprié:

réponse du client rest de VS Code pour des détails de connexion incorrects

Limiter la création de nouvelles notes aux utilisateurs connectés

Changeons la création de nouvelles notes de manière à ce qu'elle ne soit possible que si la requête post a un jeton valide attaché. La note est ensuite enregistrée dans la liste des notes de l'utilisateur identifié par le jeton.

Il existe plusieurs façons d'envoyer le jeton du navigateur au serveur. Nous utiliserons l'en-tête Authorization. L'en-tête indique également quel schéma d'authentification est utilisé. Cela peut être nécessaire si le serveur offre plusieurs façons de s'authentifier. L'identification du schéma indique au serveur comment les informations d'identification jointes doivent être interprétées.

Le schéma Bearer convient à nos besoins.

En pratique, cela signifie que si le jeton est, par exemple, la chaîne eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, l'en-tête Authorization aura la valeur :


Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW

La création de nouvelles notes changera ainsi (controllers/notes.js):

const jwt = require('jsonwebtoken')
// ...
const getTokenFrom = request => {  const authorization = request.get('authorization')  if (authorization && authorization.startsWith('Bearer ')) {    return authorization.replace('Bearer ', '')  }  return null}
notesRouter.post('/', async (request, response) => {
  const body = request.body
  const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET)  if (!decodedToken.id) {    return response.status(401).json({ error: 'token invalid' })  }  const user = await User.findById(decodedToken.id)
  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    user: user._id
  })

  const savedNote = await note.save()
  user.notes = user.notes.concat(savedNote._id)
  await user.save()

  response.json(savedNote)
})

La fonction d'aide getTokenFrom isole le jeton de l'en-tête authorization. La validité du jeton est vérifiée avec jwt.verify. La méthode décode également le jeton ou renvoie l'objet sur lequel le jeton était basé.

const decodedToken = jwt.verify(token, process.env.SECRET)

Si le jeton est manquant ou invalide, l'exception JsonWebTokenError est levée. Nous devons étendre le middleware de gestion des erreurs pour prendre en charge ce cas particulier:

const errorHandler = (error, request, response, next) => {
  logger.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } else if (error.name === 'ValidationError') {
    return response.status(400).json({ error: error.message })
  } else if (error.name ===  'JsonWebTokenError') {    return response.status(401).json({ error: error.message })  }

  next(error)
}

L'objet décodé du jeton contient les champs username et id, qui indiquent au serveur qui a effectué la requête.

Si l'objet décodé du jeton ne contient pas l'identité de l'utilisateur (si decodedToken.id est indéfini), le code d'état d'erreur 401 non autorisé est renvoyé et la raison de l'échec est expliquée dans le corps de la réponse.

if (!decodedToken.id) {
  return response.status(401).json({
    error: 'token invalid'
  })
}

Lorsque l'identité de l'auteur de la requête est résolue, l'exécution continue comme auparavant.

Une nouvelle note peut maintenant être créée en utilisant Postman si l'en-tête authorization se voit attribuer la valeur correcte, la chaîne Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, où la seconde valeur est le jeton renvoyé par l'opération login.

Avec Postman, cela ressemble à ceci:

postman ajoutant un jeton bearer

et avec le client REST de Visual Studio Code

exemple vscode ajoutant un jeton bearer

Le code actuel de l'application peut être trouvé sur Github, branche part4-9.

Si l'application a plusieurs interfaces nécessitant une identification, la validation du JWT devrait être séparée dans son propre middleware. Une bibliothèque existante comme express-jwt pourrait également être utilisée.

Problèmes de l'authentification basée sur des jetons

L'authentification par jeton est assez facile à mettre en oeuvre, mais elle contient un problème. Une fois que l'utilisateur de l'API, par exemple une application React, obtient un jeton, l'API fait entièrement confiance au détenteur du jeton. Que faire si les droits d'accès du détenteur du jeton doivent être révoqués?

Il existe deux solutions à ce problème. La plus simple consiste à limiter la période de validité d'un jeton:

loginRouter.post('/', async (request, response) => {
  const { username, password } = request.body

  const user = await User.findOne({ username })
  const passwordCorrect = user === null
    ? false
    : await bcrypt.compare(password, user.passwordHash)

  if (!(user && passwordCorrect)) {
    return response.status(401).json({
      error: 'invalid username or password'
    })
  }

  const userForToken = {
    username: user.username,
    id: user._id,
  }

  // token expires in 60*60 seconds, that is, in one hour
  const token = jwt.sign(    userForToken,     process.env.SECRET,    { expiresIn: 60*60 }  )
  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

Une fois que le jeton expire, l'application cliente doit obtenir un nouveau jeton. Habituellement, cela se fait en obligeant l'utilisateur à se reconnecter à l'application.

Le middleware de gestion des erreurs devrait être étendu pour fournir une erreur appropriée en cas de jeton expiré:

const errorHandler = (error, request, response, next) => {
  logger.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } else if (error.name === 'ValidationError') {
    return response.status(400).json({ error: error.message })
  } else if (error.name === 'JsonWebTokenError') {
    return response.status(401).json({
      error: 'invalid token'
    })
  } else if (error.name === 'TokenExpiredError') {    return response.status(401).json({      error: 'token expired'    })  }
  next(error)
}

La durée de validité plus courte, la solution est plus sûre. Ainsi, si le jeton tombe entre de mauvaises mains ou si l'accès de l'utilisateur au système doit être révoqué, le jeton n'est utilisable que pendant une durée limitée. D'autre part, une durée de validité courte force l'utilisateur à se connecter plus fréquemment au système, ce qui peut être pénible.

L'autre solution consiste à enregistrer des informations sur chaque jeton dans la base de données du backend et à vérifier pour chaque requête API si les droits d'accès correspondant aux jetons sont toujours valides. Avec ce schéma, les droits d'accès peuvent être révoqués à tout moment. Ce type de solution est souvent appelé une session côté serveur.

L'aspect négatif des sessions côté serveur est la complexité accrue dans le backend et aussi l'effet sur les performances puisque la validité du jeton doit être vérifiée pour chaque requête API dans la base de données. L'accès à la base de données est considérablement plus lent par rapport à la vérification de la validité du jeton lui-même. C'est pourquoi il est assez courant d'enregistrer la session correspondant à un jeton dans une base de données clé-valeur comme Redis, qui est limitée en fonctionnalités par rapport, par exemple, à MongoDB ou à une base de données relationnelle, mais extrêmement rapide dans certains scénarios d'utilisation.

Lorsque les sessions côté serveur sont utilisées, le jeton est souvent juste une chaîne aléatoire, qui n'inclut pas d'informations sur l'utilisateur, comme c'est souvent le cas avec les jetons jwt. Pour chaque requête API, le serveur récupère les informations pertinentes sur l'identité de l'utilisateur depuis la base de données. Il est également assez courant que, au lieu d'utiliser l'en-tête d'autorisation, les cookies soient utilisés comme mécanisme pour transférer le jeton entre le client et le serveur.

Notes de fin

Il y a eu de nombreux changements dans le code qui ont causé un problème typique pour un projet logiciel en rapide évolution: la plupart des tests ont échoué. Comme cette partie du cours est déjà saturée de nouvelles informations, nous laisserons la réparation des tests en tant qu'exercice facultatif.

Les noms d'utilisateur, les mots de passe et les applications utilisant l'authentification par jeton doivent toujours être utilisés via HTTPS. Nous pourrions utiliser un serveur HTTPS de Node dans notre application au lieu du serveur HTTP (cela nécessite plus de configuration). D'autre part, la version de production de notre application est sur Fly.io, donc notre application reste sécurisée : Fly.io achemine tout le trafic entre un navigateur et le serveur Fly.io via HTTPS.

Nous mettrons en oeuvre la connexion au frontend dans la partie suivante.

REMARQUE: À ce stade, dans l'application de prise de notes déployée, il est prévu que la fonctionnalité de création d'une note cesse de fonctionner car la fonction de connexion du backend n'est pas encore liée au frontend.