Cómo autenticarse usando GraphQL Cookies y JWT

Un ejemplo de proceso de autenticación para una API GraphQL impulsada por Apollo, usando cookies y JWT

En este tutorial explicaré cómo manejar un mecanismo de inicio de sesión para unGraphQLAPI usandoApolo.

Crearemos un área privada que, dependiendo de su inicio de sesión de usuario, mostrará información diferente.

En detalle, estos son los pasos:

  • Cree un formulario de inicio de sesión en el cliente
  • Envíe los datos de inicio de sesión al servidor
  • Autenticar al usuario y devolver un JWT
  • Almacene elJWTen una galleta
  • Use el JWT para más solicitudes a la API GraphQL

El código de este tutorial está disponible en GitHub enhttps://github.com/flaviocopes/apollo-graphql-client-server-authentication-jwt

Empecemos.

Enciendo la aplicación cliente

Creemos la parte del lado del cliente usandocreate-react-app, corrernpx create-react-app clienten una carpeta vacía.

Luego llamecd clientynpm installtodas las cosas que necesitaremos para no tener que volver más tarde:

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

El formulario de inicio de sesión

Comencemos creando el formulario de inicio de sesión.

Crear unForm.jsarchivo en elsrccarpeta y agregue este contenido en ella:

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

Aquí supongo que el servidor se ejecutarálocalhost, en el protocolo HTTP, en el puerto3000.

Yo uso React Hooks, y elAlcance enrutador. Aquí no hay código Apollo. Solo un formulario y un código para registrar una nueva cookie cuando nos autentiquemos correctamente.

Usando la API de Fetch, cuando el usuario envía el formulario, me comunico con el servidor en el/loginPunto final REST con una solicitud POST.

Cuando el servidor confirme que hemos iniciado sesión, almacenará el token JWT en una cookie y navegará al/private-areaURL, que aún no hemos creado.

Agrega el formulario a la aplicación

Editemos elindex.jsarchivo de la aplicación para utilizar este componente:

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’) )

El lado del servidor

Cambiemos del lado del servidor.

Crear unservercarpeta y ejecutarnpm init -ypara crear un listo para usarpackage.jsonexpediente.

Ahora corre

npm install express apollo-server-express cors bcrypt jsonwebtoken

A continuación, cree un archivo app.js.

Aquí, primero vamos a manejar el proceso de inicio de sesión.

Creemos algunos datos ficticios. Un usuario:

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

y algunos elementos 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'
  }
]

Los primeros 2 de ellos se asignan al usuario que acabamos de definir. El tercer elemento pertenece a otro usuario. Nuestro objetivo es iniciar sesión como usuario y mostrar solo los elementos TODO que le pertenecen.

El hash de la contraseña, por el bien del ejemplo, fue generado por mí manualmente usandobcrypt.hash()y corresponde a lassseeeecrreeetcuerda. Más información sobre bcryptaquí. En la práctica, almacenará usuarios y todos en una base de datos, y los hashes de contraseña se crean automáticamente cuando los usuarios se registran.

Manejar el proceso de inicio de sesión

Ahora, quiero manejar el proceso de inicio de sesión.

Cargué un montón de bibliotecas que vamos a usar e inicializo Express para usarCORS, para que podamos usarlo desde nuestra aplicación cliente (ya que está en otro puerto), y agrego el middleware que analiza los datos codificados en urlen:

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
}))

A continuación defino unSECRET_KEYque usaremos para la firma de JWT, y defino el/loginControlador de punto final POST. Hay unasyncpalabra clave porque vamos a utilizarawaiten el código. Extraigo los campos de correo electrónico y contraseña del cuerpo de la solicitud y busco al usuario en nuestrousers"base de datos".

Si el correo electrónico no encuentra al usuario, le devuelvo un mensaje de error.

A continuación, verifico si la contraseña no coincide con el hash que tenemos y, si es así, envío un mensaje de error.

Si todo va bien, genero el token usando eljwt.sign()llamar, pasando elemailyidcomo datos de usuario, y lo envío al cliente como parte de la respuesta.

Aquí está el código:

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, }) })

Ahora puedo iniciar la aplicación Express:

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

El area privada

En este punto, del lado del cliente agrego el token a las cookies y me muevo al/private-areaURL.

¿Qué hay en esa URL? ¡Nada! Agreguemos un componente para manejar eso, ensrc/PrivateArea.js:

import React from 'react'

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

export default PrivateArea

Enindex.js, podemos agregar esto a la aplicación:

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’) )

Yo uso lo buenojs-cookiebiblioteca para trabajar fácilmente con cookies. Al usarlo, compruebo si haytokenen las cookies. Si no es así, simplemente regrese al formulario de inicio de sesión:

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

¡Ahora, en teoría, estamos listos para usar la API GraphQL! Pero todavía no tenemos tal cosa. Hagamos esa cosa.

La API GraphQL

Del lado del servidor, hago todo en un solo archivo. No es tan grande, ya que tenemos pequeñas cosas en su lugar.

Añado esto en la parte superior del archivo:

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

lo que nos da todo lo que necesitamos para crear el servidor Apollo GraphQL.

Necesito definir 3 cosas:

  • el esquema GraphQL
  • resolutores
  • el contexto

Aquí está el esquema. Yo defino elUsertype, que representa lo que tenemos en el objeto de nuestros usuarios. Entonces elTodotipo, y finalmente elQuerytype, que establece lo que podemos consultar directamente: la lista de todos.

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] } `

losQuerytype tiene una entrada y necesitamos definir un resolutor para ella. Aquí lo tienes:

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

Luego, el contexto, donde básicamente verificamos el token y el error si no es válido, y obtenemos elidyemailvalores de ella. Así es como sabemosquiénestá hablando con la 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’, ) } }

losidyemailLos valores ahora están disponibles dentro de nuestro resolver (s). Ahí es donde elidel valor que usamos arriba proviene.

Necesitamos agregar Apollo a Express como middleware ahora, ¡y la parte del lado del servidor está terminada!

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

El cliente de Apollo

¡Estamos listos para inicializar nuestro cliente Apollo ahora!

En el lado del clienteindex.jsarchivo agrego esas bibliotecas:

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'

Inicializo un objeto HttpLink que apunta al servidor API GraphQL, escuchando en el puerto 3000 de localhost, en el/graphqlendpoint, y utilícelo para configurar elApolloClientobjeto.

Un HttpLink nos proporciona una forma de describir cómo queremos obtener el resultado de una operación GraphQL y qué queremos hacer con la respuesta.

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 tenemos un token, navego al área privada:

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

y finalmente utilizo elApolloProvidercomponente que importamos como componente principal y envolvemos todo en la aplicación que definimos. De esta forma podemos acceder a laclientobjeto en cualquiera de nuestros componentes secundarios. En particular, el PrivateArea, ¡muy pronto!

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

El area privada

Entonces estamos en el último paso. ¡Ahora finalmente podemos realizar nuestra consulta GraphQL!

Esto es lo que tenemos ahora:

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

Voy a importar estos 2 elementos de 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: