Comment s'authentifier à l'aide des cookies GraphQL et JWT

Un exemple de processus d'authentification pour une API GraphQL alimentée par Apollo, utilisant des cookies et JWT

Dans ce tutoriel, je vais vous expliquer comment gérer un mécanisme de connexion pour unGraphQLAPI utilisantApollon.

Nous allons créer une zone privée qui, en fonction de votre connexion utilisateur, affichera des informations différentes.

En détail, voici les étapes:

  • Créez un formulaire de connexion sur le client
  • Envoyez les données de connexion au serveur
  • Authentifiez l'utilisateur et renvoyez un JWT
  • Stocker leJWTdans un cookie
  • Utilisez le JWT pour d'autres requêtes à l'API GraphQL

Le code de ce tutoriel est disponible sur GitHub à l'adressehttps://github.com/flaviocopes/apollo-graphql-client-server-authentication-jwt

Commençons.

Je lance l'application client

Créons la partie côté client en utilisantcreate-react-app, Coursnpx create-react-app clientdans un dossier vide.

Puis appelezcd clientetnpm installtout ce dont nous aurons besoin pour ne pas avoir besoin de revenir plus tard:

npm install apollo-client apollo-boost apollo-link-http apollo-cache-inmemory react-apollo apollo-link-context @reach/router js-cookie graphql-tag

Le formulaire de connexion

Commençons par créer le formulaire de connexion.

Créer unForm.jsfichier dans lesrcdossier, et ajoutez-y ce contenu:

import React, { useState } from 'react'
import { navigate } from '@reach/router'

const url = http://localhost:3000/login

const Form = () => { const [email, setEmail] = useState(’’) const [password, setPassword] = useState(’’)

const submitForm = event => { event.preventDefault()

<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">options</span> <span style="color:#f92672">=</span> {
  <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">'post'</span>,
  <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> {
    <span style="color:#e6db74">'Content-type'</span><span style="color:#f92672">:</span> <span style="color:#e6db74">'application/x-www-form-urlencoded; charset=UTF-8'</span>
  },
  <span style="color:#a6e22e">body</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`email=</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">email</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&amp;password=</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">password</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>
}

<span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">url</span>, <span style="color:#a6e22e">options</span>)
.<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">response</span> =&gt; {
  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">response</span>.<span style="color:#a6e22e">ok</span>) {
    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">response</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">404</span>) {
      <span style="color:#a6e22e">alert</span>(<span style="color:#e6db74">'Email not found, please retry'</span>)
    }
    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">response</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">401</span>) {
      <span style="color:#a6e22e">alert</span>(<span style="color:#e6db74">'Email and password do not match, please retry'</span>)
    }
  }
  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">response</span>
})
.<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">response</span> =&gt; <span style="color:#a6e22e">response</span>.<span style="color:#a6e22e">json</span>())
.<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">data</span> =&gt; {
  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">data</span>.<span style="color:#a6e22e">success</span>) {
    document.<span style="color:#a6e22e">cookie</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">'token='</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">data</span>.<span style="color:#a6e22e">token</span>
    <span style="color:#a6e22e">navigate</span>(<span style="color:#e6db74">'/private-area'</span>)
  }
})

}

return ( <div> <form onSubmit={submitForm}> <p>Email: <input type=“text” onChange={event => setEmail(event.target.value)} /></p> <p>Password: <input type=“password” onChange={event => setPassword(event.target.value)} /></p> <p><button type=“submit”>Login</button></p> </form> </div> ) }

export default Form

Ici, je suppose que le serveur fonctionnera surlocalhost, sur le protocole HTTP, sur le port3000.

J'utilise React Hooks, et leAtteindre le routeur. Il n'y a pas de code Apollo ici. Juste un formulaire et du code pour enregistrer un nouveau cookie lorsque nous nous authentifions avec succès.

En utilisant l'API Fetch, lorsque le formulaire est envoyé par l'utilisateur, je contacte le serveur sur le/loginPoint de terminaison REST avec une requête POST.

Lorsque le serveur confirmera que nous sommes connectés, il stockera le jeton JWT dans un cookie, et il naviguera vers le/private-areaURL, que nous n'avons pas encore créée.

Ajouter le formulaire à l'application

Modifions leindex.jsfichier de l'application pour utiliser ce composant:

import React from 'react'
import ReactDOM from 'react-dom'
import { Router } from '@reach/router'
import Form from './Form'

ReactDOM.render( <Router> <Form path="/" /> </Router> document.getElementById(‘root’) )

Le côté serveur

Changeons côté serveur.

Créer unserverdossier et exécuteznpm init -ypour créer un prêt à l'emploipackage.jsondéposer.

Maintenant, cours

npm install express apollo-server-express cors bcrypt jsonwebtoken

Ensuite, créez un fichier app.js.

Ici, nous allons d'abord gérer le processus de connexion.

Créons des données factices. Un utilisateur:

const users = [{
  id: 1,
  name: 'Test user',
  email: '[email protected]',
  password: '$2b$10$ahs7h0hNH8ffAVg6PwgovO3AVzn1izNFHn.su9gcJnUWUzb2Rcb2W' // = ssseeeecrreeet
}]

et quelques articles TODO:

const todos = [
  {
    id: 1,
    user: 1,
    name: 'Do something'
  },
  {
    id: 2,
    user: 1,
    name: 'Do something else'
  },
  {
    id: 3,
    user: 2,
    name: 'Remember the milk'
  }
]

Les 2 premiers d'entre eux sont attribués à l'utilisateur que nous venons de définir. Le troisième élément appartient à un autre utilisateur. Notre objectif est de se connecter à l'utilisateur et d'afficher uniquement les éléments TODO qui lui appartiennent.

Le hachage du mot de passe, pour les besoins de l'exemple, a été généré par moi manuellement en utilisantbcrypt.hash()et correspond aussseeeecrreeetchaîne de caractères. Plus d'informations sur bcryptici. En pratique, vous stockerez les utilisateurs et les todos dans une base de données, et les hachages de mots de passe sont créés automatiquement lors de l'inscription des utilisateurs.

Gérez le processus de connexion

Maintenant, je veux gérer le processus de connexion.

Je charge un tas de bibliothèques que nous allons utiliser et j'initialise Express pour l'utiliserCORS, afin que nous puissions l'utiliser à partir de notre application client (comme c'est sur un autre port), et j'ajoute le middleware qui analyse les données urlencodées:

const express = require('express')
const cors = require('cors')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const app = express()
app.use(cors())
app.use(express.urlencoded({
  extended: true
}))

Ensuite, je définis unSECRET_KEYnous allons utiliser pour la signature JWT, et je définis le/loginGestionnaire de point de terminaison POST. Il y a unasyncmot-clé parce que nous allons utiliserawaitdans le code. J'extrais les champs email et mot de passe du corps de la demande et je recherche l'utilisateur dans notreusers"base de données".

Si l'utilisateur n'est pas trouvé par son e-mail, je renvoie un message d'erreur.

Ensuite, je vérifie si le mot de passe ne correspond pas au hachage que nous avons et je renvoie un message d'erreur si c'est le cas.

Si tout se passe bien, je génère le jeton en utilisant lejwt.sign()appel, en passant leemailetiden tant que données utilisateur, et je l'envoie au client dans le cadre de la réponse.

Voici le code:

const SECRET_KEY = 'secret!'

app.post(’/login’, async (req, res) => { const { email, password } = req.body const theUser = users.find(user => user.email === email)

if (!theUser) { res.status(404).send({ success: false, message: Could not find account: </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">email</span><span style="color:#e6db74">}</span><span style="color:#e6db74">, }) return }

const match = await bcrypt.compare(password, theUser.password) if (!match) { //return error to user to let them know the password is incorrect res.status(401).send({ success: false, message: ‘Incorrect credentials’, }) return }

const token = jwt.sign( { email: theUser.email, id: theUser.id }, SECRET_KEY, )

res.send({ success: true, token: token, }) })

Je peux maintenant démarrer l'application Express:

app.listen(3000, () =>
  console.log('Server listening on port 3000')
)

L'espace privé

À ce stade, côté client, j'ajoute le jeton aux cookies et passe au/private-areaURL.

Qu'y a-t-il dans cette URL? Rien! Ajoutons un composant pour gérer cela, danssrc/PrivateArea.js:

import React from 'react'

const PrivateArea = () => { return ( <div> Private area! </div> ) }

export default PrivateArea

Dansindex.js, nous pouvons ajouter ceci à l'application:

import React from 'react'
import ReactDOM from 'react-dom'
import { Router } from '@reach/router'
import Form from './Form'
import PrivateArea from './PrivateArea'

ReactDOM.render( <Router> <Form path="/" /> <PrivateArea path="/private-area" /> </Router> document.getElementById(‘root’) )

J'utilise le gentiljs-cookiebibliothèque pour travailler facilement avec les cookies. En l'utilisant, je vérifie s'il y a letokendans les cookies. Sinon, retournez simplement au formulaire de connexion:

import React from 'react'
import Cookies from 'js-cookie'
import { navigate } from '@reach/router'

const PrivateArea = () => { if (!Cookies.get(‘token’)) { navigate(’/’) }

return ( <div> Private area! </div> ) }

export default PrivateArea

Maintenant, en théorie, nous sommes tous prêts à utiliser l'API GraphQL! Mais nous n'avons encore rien de tel. Faisons cette chose.

L'API GraphQL

Côté serveur, je fais tout dans un seul fichier. Ce n'est pas si gros, car nous avons de petites choses en place.

J'ajoute ceci en haut du fichier:

const {
  ApolloServer,
  gql,
  AuthenticationError,
} = require('apollo-server-express')

ce qui nous donne tout ce dont nous avons besoin pour créer le serveur Apollo GraphQL.

J'ai besoin de définir 3 choses:

  • le schéma GraphQL
  • résolveurs
  • le contexte

Voici le schéma. Je définis leUsertype, qui représente ce que nous avons dans notre objet utilisateurs. Puis leTodotype, et enfin leQuerytype, qui définit ce que nous pouvons directement interroger: la liste des tâches.

const typeDefs = gql`
  type User {
    id: ID!
    email: String!
    name: String!
    password: String!
  }

type Todo { id: ID! user: Int! name: String! }

type Query { todos: [Todo] } `

LeQuerytype a une entrée, et nous devons définir un résolveur pour cela. C'est ici:

const resolvers = {
  Query: {
    todos: (root, args) => {
      return todos.filter(todo => todo.user === id)
    }
  }
}

Ensuite, le contexte, où nous vérifions essentiellement le jeton et l'erreur s'il est invalide, et nous obtenons leidetemailvaleurs de celui-ci. C'est ainsi que nous savonsquiparle à l'API:

const context = ({ req }) => {
  const token = req.headers.authorization || ''

try { return { id, email } = jwt.verify(token.split(’ ')[1], SECRET_KEY) } catch (e) { throw new AuthenticationError( ‘Authentication token is invalid, please log in’, ) } }

Leidetemailles valeurs sont maintenant disponibles dans notre (nos) résolveur (s). C'est là que leidla valeur que nous utilisons ci-dessus vient de.

Nous devons maintenant ajouter Apollo à Express en tant que middleware, et la partie côté serveur est terminée!

const server = new ApolloServer({ typeDefs, resolvers, context })
server.applyMiddleware({ app })

Le client Apollo

Nous sommes prêts à initialiser notre client Apollo maintenant!

Du côté clientindex.jsfichier j'ajoute ces bibliothèques:

import { ApolloClient } from 'apollo-client'
import { createHttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { ApolloProvider } from 'react-apollo'
import { setContext } from 'apollo-link-context'
import { navigate } from '@reach/router'
import Cookies from 'js-cookie'
import gql from 'graphql-tag'

J'initialise un objet HttpLink qui pointe vers le serveur API GraphQL, en écoute sur le port 3000 de localhost, sur le/graphqlendpoint, et utilisez-le pour configurer leApolloClientobjet.

Un HttpLink nous fournit un moyen de décrire comment nous voulons obtenir le résultat d'une opération GraphQL, et ce que nous voulons faire avec la réponse.

const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql' })

const authLink = setContext((_, { headers }) => { const token = Cookies.get(‘token’)

return { headers: { …headers, authorization: Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> } } })

const client = new ApolloClient({ link: authLink.concat(httpLink), cache: new InMemoryCache() })

Si nous avons un jeton, je navigue vers la zone privée:

if (Cookies.get('token')) {
  navigate('/private-area')
}

et enfin j'utilise leApolloProvidercomposant que nous avons importé en tant que composant parent et enveloppez tout dans l'application que nous avons définie. De cette façon, nous pouvons accéder auclientobjet dans l'un de nos composants enfants. En particulier celui de PrivateArea, très bientôt!

ReactDOM.render(
  <ApolloProvider client={client}>
    <Router>
      <Form path="/" />
      <PrivateArea path="/private-area" />
    </Router>
  </ApolloProvider>,
  document.getElementById('root')
)

L'espace privé

Nous sommes donc à la dernière étape. Maintenant, nous pouvons enfin effectuer notre requête GraphQL!

Voici ce que nous avons maintenant:

import React from 'react'
import Cookies from 'js-cookie'
import { navigate } from '@reach/router'

const PrivateArea = () => { if (!Cookies.get(‘token’)) { navigate(’/’) }

return ( <div> Private area! </div> ) }

export default PrivateArea

Je vais importer ces 2 éléments depuis Apollo:

import { gql } from 'apollo-boost'
import { Query } from 'react-apollo'

and instead of

  return (
    <div>
      Private area!
    </div>
  )

I’m going to use the Query component and pass a GraphQL query. Inside the component body we pass a function that takes an object with 3 properties: loading, error and data.

While the data is not available yet, loading is true and we can add a message to the user. If there’s any error we’ll get it back, but otherwise we’ll get our TO-DO items in the data object and we can iterate over them to render our items to the user!

  return (
    <div>
      <Query
        query={gql`
          {
            todos {
              id
              name
            }
          }
        `}
      >
        {({ loading, error, data }) => {
          if (loading) return <p>Loading...</p>
          if (error) {
            navigate('/')
            return <p></p>
          }
          return <ul>{data.todos.map(item => <li key={item.id}>{item.name}</li>)}</ul>
        }}
      </Query>
    </div>
  )

Now that things are working, I want to change a little bit how the code works and add the use of HTTPOnly cookies. This special kind of cookie is more secure because we can’t access it using JavaScript, and as such it can’t be stolen by 3rd part scripts and used as a target for attacks.

Things are a bit more complex now so I added this at the bottom.

All the code is available on GitHub at https://github.com/flaviocopes/apollo-graphql-client-server-authentication-jwt and all I described up to now is available in this commit.

The code for this last part is available in this separate commit.

First, in the client, in Form.js, instead of adding the token to a cookie, I add a signedin cookie.

Remove this

document.cookie = 'token=' + data.token

and add

document.cookie = 'signedin=true'

Next, in the fetch options we must add

credentials: 'include'

otherwise fetch won’t store in the browser the cookies it gets back from the server.

Now in the PrivateArea.js file we don’t check for the token cookie, but for the signedin cookie:

Remove

if (!Cookies.get('token')) {

and add

if (!Cookies.get('signedin')) {

Let’s go to the server part.

First install the cookie-parser library with npm install cookie-parser and instead of sending back the token to the client:

res.send({
  success: true,
  token: token,
})

Only send this:

res.send({
  success: true
})

We send the JWT token to the user as an HTTPOnly cookie:

res.cookie('jwt', token, {
  httpOnly: true
  //secure: true, //on HTTPS
  //domain: 'example.com', //set your domain
})

(in production set the secure option on HTTPS and also the domain)

Next we need to set the CORS middleware to use cookies, too. Otherwise things will break very soon when we manage the GraphQL data, as cookies just disappear.

Change

app.use(cors())

with

const corsOptions = {
  origin: 'http://localhost:3001', //change with your own client URL
  credentials: true
}

app.use(cors(corsOptions)) app.use(cookieParser())

Back to the client, in index.js we tell Apollo Client to include credentials (cookies) in its requests. Switch:

const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql' })

with

const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql', credentials: 'include' })

and remove the authLink definition altogether:

const authLink = setContext((_, { headers }) => {
  const token = Cookies.get('token')

return { headers: { …headers, authorization: Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> } } })

as we don’t need it any more. We’ll just pass httpLink to new ApolloClient(), since we don’t need more customized authentication stuff:

const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache()
})

Back to the server for the last piece of the puzzle! Open index.js and in the context function definition, change

const token = req.headers.authorization || ''

with

const token = req.cookies['jwt'] || ''

and disable the Apollo Server built-in CORS handling, since it overwrites the one we already did in Express, change:

const server = new ApolloServer({ typeDefs, resolvers, context })
server.applyMiddleware({ app })

to

const server = new ApolloServer({ typeDefs, resolvers, context,
  cors: false })
server.applyMiddleware({ app, cors: false })

More graphql tutorials: