Aller au contenu

a

Connexion front-end

Dans les deux dernières parties, nous nous sommes principalement concentrés sur le backend. Le frontend que nous avons développé dans la partie 2 ne prend pas encore en charge la gestion des utilisateurs que nous avons mise en oeuvre dans le backend de la partie 4.

Pour le moment, le frontend affiche les notes existantes et permet aux utilisateurs de changer l'état d'une note de important à non important et vice versa. Il n'est plus possible d'ajouter de nouvelles notes en raison des changements apportés au backend dans la partie 4: le backend attend désormais qu'un jeton vérifiant l'identité d'un utilisateur soit envoyé avec la nouvelle note.

Nous allons maintenant mettre en oeuvre une partie de la fonctionnalité de gestion des utilisateurs requise dans le frontend. Commençons par la connexion des utilisateurs. Tout au long de cette partie, nous supposerons que de nouveaux utilisateurs ne seront pas ajoutés depuis le frontend.

Gestion de la connexion

Un formulaire de connexion a maintenant été ajouté en haut de la page:

navigateur affichant la connexion utilisateur pour les notes

Le code du composant App se présente désormais comme suit:

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState(null)
  const [username, setUsername] = useState('')   const [password, setPassword] = useState('') 
  useEffect(() => {
    noteService
      .getAll().then(initialNotes => {
        setNotes(initialNotes)
      })
  }, [])

  // ...

  const handleLogin = (event) => {    event.preventDefault()    console.log('logging in with', username, password)  }
  return (
    <div>
      <h1>Notes</h1>

      <Notification message={errorMessage} />

      <form onSubmit={handleLogin}>        <div>          username            <input            type="text"            value={username}            name="Username"            onChange={({ target }) => setUsername(target.value)}          />        </div>        <div>          password            <input            type="password"            value={password}            name="Password"            onChange={({ target }) => setPassword(target.value)}          />        </div>        <button type="submit">login</button>      </form>
      // ...
    </div>
  )
}

export default App

Le code de l'application actuelle peut être trouvé sur Github, branche part5-1. Si vous clonez le repo, n'oubliez pas d'exécuter npm install avant d'essayer d'exécuter le frontend.

Le frontend n'affichera aucune note s'il n'est pas connecté au backend. Vous pouvez démarrer le backend avec npm run dev dans son dossier de la Partie 4. Cela exécutera le backend sur le port 3001. Pendant que cela est actif, dans une fenêtre de terminal séparée, vous pouvez démarrer le frontend avec npm start, et maintenant vous pouvez voir les notes qui sont sauvegardées dans votre base de données MongoDB de la Partie 4.

Gardez cela à l'esprit à partir de maintenant.

Le formulaire de connexion est géré de la même manière que nous avons géré les formulaires dans la partie 2. L'état de l'application a des champs pour username (nom d'utilisateur) et password (mot de passe) pour stocker les données du formulaire. Les champs du formulaire ont des gestionnaires d'événements, qui synchronisent les changements dans le champ avec l'état du composant App. Les gestionnaires d'événements sont simples : un objet leur est donné en paramètre, et ils déstructurent le champ target de l'objet et sauvegardent sa valeur dans l'état.

({ target }) => setUsername(target.value)

La méthode handleLogin, qui est responsable de la gestion des données du formulaire, reste à implémenter.

La connexion se fait en envoyant une requête HTTP POST à l'adresse du serveur api/login. Séparons le code responsable de cette requête dans son propre module, dans le fichier services/login.js.

Nous utiliserons la syntaxe async/await au lieu des promesses pour la requête HTTP:

import axios from 'axios'
const baseUrl = '/api/login'

const login = async credentials => {
  const response = await axios.post(baseUrl, credentials)
  return response.data
}

export default { login }

La méthode pour gérer la connexion peut être implémentée comme suit:

import loginService from './services/login'
const App = () => {
  // ...
  const [username, setUsername] = useState('') 
  const [password, setPassword] = useState('') 
  const [user, setUser] = useState(null)  
  const handleLogin = async (event) => {    event.preventDefault()        try {      const user = await loginService.login({        username, password,      })      setUser(user)      setUsername('')      setPassword('')    } catch (exception) {      setErrorMessage('Wrong credentials')      setTimeout(() => {        setErrorMessage(null)      }, 5000)    }  }

  // ...
}

Si la connexion est réussie, les champs du formulaire sont vidés et la réponse du serveur (incluant un jeton et les détails de l'utilisateur) est sauvegardée dans le champ user de l'état de l'application.

Si la connexion échoue ou si l'exécution de la fonction loginService.login résulte en une erreur, l'utilisateur est notifié.

L'utilisateur n'est pas informé d'une connexion réussie de quelque manière que ce soit. Modifions l'application pour afficher le formulaire de connexion uniquement si l'utilisateur n'est pas connecté, donc quand user === null. Le formulaire pour ajouter de nouvelles notes est montré uniquement si l'utilisateur est connecté, donc user contient les détails de l'utilisateur.

Ajoutons deux fonctions d'aide au composant App pour générer les formulaires:

const App = () => {
  // ...

  const loginForm = () => (
    <form onSubmit={handleLogin}>
      <div>
        username
          <input
          type="text"
          value={username}
          name="Username"
          onChange={({ target }) => setUsername(target.value)}
        />
      </div>
      <div>
        password
          <input
          type="password"
          value={password}
          name="Password"
          onChange={({ target }) => setPassword(target.value)}
        />
      </div>
      <button type="submit">login</button>
    </form>      
  )

  const noteForm = () => (
    <form onSubmit={addNote}>
      <input
        value={newNote}
        onChange={handleNoteChange}
      />
      <button type="submit">save</button>
    </form>  
  )

  return (
    // ...
  )
}

et les rendre conditionnellement:

const App = () => {
  // ...

  const loginForm = () => (
    // ...
  )

  const noteForm = () => (
    // ...
  )

  return (
    <div>
      <h1>Notes</h1>

      <Notification message={errorMessage} />

      {user === null && loginForm()}      {user !== null && noteForm()}
      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all'}
        </button>
      </div>
      <ul>
        {notesToShow.map((note, i) => 
          <Note
            key={i}
            note={note} 
            toggleImportance={() => toggleImportanceOf(note.id)}
          />
        )}
      </ul>

      <Footer />
    </div>
  )
}

Une astuce légèrement inhabituelle mais fréquemment utilisée en React est utilisée pour le rendu conditionnel des formulaires:

{
  user === null && loginForm()
}

Si la première instruction est évaluée à faux ou est considérée comme falsy, la seconde instruction (générant le formulaire) n'est pas du tout exécutée.

Nous pouvons rendre cela encore plus direct en utilisant l'opérateur conditionnel:

return (
  <div>
    <h1>Notes</h1>

    <Notification message={errorMessage}/>

    {user === null ?
      loginForm() :
      noteForm()
    }

    <h2>Notes</h2>

    // ...

  </div>
)

Si user === null est considéré comme truthy, loginForm() est exécuté. Sinon, c'est noteForm() qui l'est.

Faisons une dernière modification. Si l'utilisateur est connecté, son nom est affiché à l'écran:

return (
  <div>
    <h1>Notes</h1>

    <Notification message={errorMessage} />

    {!user && loginForm()} 
    {user && <div>
       <p>{user.name} logged in</p>
         {noteForm()}
      </div>
    }

    <h2>Notes</h2>

    // ...

  </div>
)

La solution n'est pas parfaite, mais nous allons la laisser telle quelle pour l'instant.

Notre composant principal App est actuellement bien trop volumineux. Les changements que nous avons effectués maintenant sont un signe clair que les formulaires devraient être refactorisés dans leurs propres composants. Cependant, nous laisserons cela pour un exercice optionnel.

Le code actuel de l'application peut être trouvé sur GitHub, branche part5-2.

Création de nouvelles notes

Le jeton renvoyé avec une connexion réussie est sauvegardé dans l'état de l'application - le champ token de l'utilisateur:

const handleLogin = async (event) => {
  event.preventDefault()
  try {
    const user = await loginService.login({
      username, password,
    })

    setUser(user)    setUsername('')
    setPassword('')
  } catch (exception) {
    // ...
  }
}

Réparons la création de nouvelles notes pour qu'elle fonctionne avec le backend. Cela signifie ajouter le jeton de l'utilisateur connecté à l'en-tête d'autorisation de la requête HTTP.

Le module noteService change ainsi:

import axios from 'axios'
const baseUrl = '/api/notes'

let token = null
const setToken = newToken => {  token = `Bearer ${newToken}`}
const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = async newObject => {  const config = {    headers: { Authorization: token },  }
  const response = await axios.post(baseUrl, newObject, config)  return response.data
}

const update = (id, newObject) => {
  const request = axios.put(`${ baseUrl }/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update, setToken }

Le module noteService contient une variable privée token. Sa valeur peut être modifiée avec une fonction setToken, qui est exportée par le module. create, maintenant avec la syntaxe async/await, définit le jeton dans l'en-tête Authorization. L'en-tête est donné à axios comme le troisième paramètre de la méthode post.

Le gestionnaire d'événements responsable de la connexion doit être modifié pour appeler la méthode noteService.setToken(user.token) lors d'une connexion réussie:

const handleLogin = async (event) => {
  event.preventDefault()
  try {
    const user = await loginService.login({
      username, password,
    })

    noteService.setToken(user.token)    setUser(user)
    setUsername('')
    setPassword('')
  } catch (exception) {
    // ...
  }
}

Et maintenant, l'ajout de nouvelles notes fonctionne à nouveau!

Sauvegarder le jeton dans le stockage local du navigateur

Notre application a un petit défaut : si le navigateur est actualisé (par exemple, en appuyant sur F5), les informations de connexion de l'utilisateur disparaissent.

Ce problème est facilement résolu en sauvegardant les détails de connexion dans le stockage local. Le stockage local est une base de données clé-valeur dans le navigateur.

Il est très facile à utiliser. Une valeur correspondant à une certaine clé est sauvegardée dans la base de données avec la méthode setItem. Par exemple:

window.localStorage.setItem('name', 'juha tauriainen')

sauvegarde la chaîne donnée comme deuxième paramètre comme la valeur de la clé name.

La valeur d'une clé peut être trouvée avec la méthode getItem:

window.localStorage.getItem('name')

et removeItem supprime une clé.

Les valeurs dans le stockage local sont persistées même lorsque la page est réaffichée. Le stockage est spécifique à l'origine, donc chaque application web a son propre stockage.

Étendons notre application de sorte qu'elle sauvegarde les détails d'un utilisateur connecté dans le stockage local.

Les valeurs sauvegardées dans le stockage sont des DOMstrings, donc nous ne pouvons pas sauvegarder un objet JavaScript tel quel. L'objet doit d'abord être converti en JSON, avec la méthode JSON.stringify. De manière correspondante, lorsqu'un objet JSON est lu depuis le stockage local, il doit être reconverti en JavaScript avec JSON.parse.

Les modifications apportées à la méthode de connexion sont les suivantes:

  const handleLogin = async (event) => {
    event.preventDefault()
    try {
      const user = await loginService.login({
        username, password,
      })

      window.localStorage.setItem(        'loggedNoteappUser', JSON.stringify(user)      )       noteService.setToken(user.token)
      setUser(user)
      setUsername('')
      setPassword('')
    } catch (exception) {
      // ...
    }
  }

Les détails d'un utilisateur connecté sont maintenant sauvegardés dans le stockage local, et ils peuvent être consultés dans la console (en tapant window.localStorage dans la console) :

navigateur montrant quelqu'un connecté aux notes

Vous pouvez également inspecter le stockage local en utilisant les outils de développement. Sur Chrome, allez à l'onglet Application et sélectionnez Stockage local (plus de détails ici)). Sur Firefox, allez à l'onglet Stockage et sélectionnez Stockage local (détails ici).

Nous devons encore modifier notre application pour que, lorsque nous entrons sur la page, l'application vérifie si les détails d'un utilisateur connecté peuvent déjà être trouvés dans le stockage local. Si c'est le cas, les détails sont sauvegardés dans l'état de l'application et dans noteService.

La bonne manière de faire cela est avec un hook d'effet: un mécanisme que nous avons rencontré pour la première fois dans la partie 2, et utilisé pour récupérer des notes depuis le serveur.

Nous pouvons avoir plusieurs hooks d'effet, créons-en donc un second pour gérer le premier chargement de la page:

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState(null)
  const [username, setUsername] = useState('') 
  const [password, setPassword] = useState('') 
  const [user, setUser] = useState(null) 

  useEffect(() => {
    noteService
      .getAll().then(initialNotes => {
        setNotes(initialNotes)
      })
  }, [])

  useEffect(() => {    const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser')    if (loggedUserJSON) {      const user = JSON.parse(loggedUserJSON)      setUser(user)      noteService.setToken(user.token)    }  }, [])
  // ...
}

Le tableau vide comme paramètre de l'effet garantit que l'effet est exécuté uniquement lorsque le composant est rendu pour la première fois.

Maintenant, un utilisateur reste connecté à l'application indéfiniment. Nous devrions probablement ajouter une fonctionnalité de déconnexion qui supprime les détails de connexion du stockage local. Nous le laisserons cependant comme exercice.

Il est possible de déconnecter un utilisateur en utilisant la console, et cela suffit pour le moment. Vous pouvez vous déconnecter avec la commande:

window.localStorage.removeItem('loggedNoteappUser')

ou avec la commande qui vide complètement le localstorage:

window.localStorage.clear()

Le code actuel de l'application peut être trouvé sur GitHub, branche part5-3.

Remarque sur l'utilisation du stockage local

À la fin de la dernière partie, nous avons mentionné que le défi de l'authentification basée sur les jetons est de savoir comment faire face à la situation où l'accès à l'API du détenteur du jeton doit être révoqué.

Il existe deux solutions à ce problème. La première consiste à limiter la période de validité d'un jeton. Cela oblige l'utilisateur à se reconnecter à l'application une fois le jeton expiré. L'autre approche consiste à sauvegarder les informations de validité de chaque jeton dans la base de données du backend. Cette solution est souvent appelée une session côté serveur.

Peu importe comment la validité des jetons est vérifiée et assurée, sauvegarder un jeton dans le stockage local peut contenir un risque de sécurité si l'application a une vulnérabilité de sécurité qui permet des attaques Cross Site Scripting (XSS). Une attaque XSS est possible si l'application permettait à un utilisateur d'injecter un code JavaScript arbitraire (par exemple, en utilisant un formulaire) que l'application exécuterait ensuite. Lors de l'utilisation sensée de React, cela ne devrait pas être possible puisque React assainit tout le texte qu'il rend, ce qui signifie qu'il n'exécute pas le contenu rendu en tant que JavaScript.

Si l'on veut jouer la sécurité, la meilleure option est de ne pas stocker un jeton dans le stockage local. Cela pourrait être une option dans des situations où la fuite d'un jeton pourrait avoir des conséquences tragiques.

Il a été suggéré que l'identité d'un utilisateur connecté devrait être sauvegardée sous forme de cookies httpOnly, de sorte que le code JavaScript ne puisse avoir aucun accès au jeton. L'inconvénient de cette solution est qu'elle rendrait la mise en oeuvre d'applications SPA un peu plus complexe. Il serait nécessaire au moins de mettre en oeuvre une page séparée pour la connexion.

Cependant, il est bon de noter que même l'utilisation de cookies httpOnly ne garantit rien. Il a même été suggéré que les cookies httpOnly ne sont pas plus sûrs que l'utilisation du stockage local.

Donc, quelle que soit la solution utilisée, la chose la plus importante est de minimiser le risque d'attaques XSS dans son ensemble.