d

Autenticación de token

Los usuarios deben poder iniciar sesión en nuestra aplicación , y cuando un usuario inicia sesión, su información de usuario debe adjuntarse automáticamente a cualquier nota nueva que cree.

Ahora implementaremos soporte para autenticación basada en token para el backend.

Los principios de la autenticación basada en tokens se describen en el siguiente diagrama de secuencia:

fullstack content
  • El usuario comienza iniciando sesión usando un formulario de inicio de sesión implementado con React

    • Agregaremos el formulario de inicio de sesión a la interfaz en parte 5
  • Esto hace que el código React envíe el nombre de usuario y la contraseña a la dirección del servidor /api/login como una solicitud HTTP POST.
  • Si el nombre de usuario y la contraseña son correctos, el servidor genera un token que identifica de alguna manera al usuario que inició sesión.

    • El token está firmado digitalmente, por lo que es imposible falsificarlo (con medios criptográficos)
  • El backend responde con un código de estado que indica que la operación fue exitosa y devuelve el token con la respuesta.
  • El navegador guarda el token, por ejemplo, en el estado de una aplicación React.
  • Cuando el usuario crea una nueva nota (o realiza alguna otra operación que requiera identificación), el código React envía el token al servidor con la solicitud.
  • El servidor usa el token para identificar al usuario

Primero implementemos la funcionalidad para iniciar sesión. Instale la librería jsonwebtoken, que nos permite generar tokens web JSON.

npm install jsonwebtoken

El código para la funcionalidad de inicio de sesión va a los controladores de 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 body = request.body

  const user = await User.findOne({ username: body.username })
  const passwordCorrect = user === null
    ? false
    : await bcrypt.compare(body.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

El código comienza buscando al usuario en la base de datos por el nombre de usuario adjunto a la solicitud. A continuación, verifica la contraseña, también adjunta a la solicitud. Debido a que las contraseñas en sí no se guardan en la base de datos, sino hash calculadas a partir de las contraseñas, el método bcrypt.compare se usa para verificar si la contraseña es correcta:

await bcrypt.compare(body.password, user.passwordHash)

Si no se encuentra el usuario o la contraseña es incorrecta, se responde a la solicitud con el código de estado 401 unauthorized. El motivo del error se explica en el cuerpo de respuesta.

Si la contraseña es correcta, se crea un token con el método jwt.sign. El token contiene el nombre de usuario y la identificación de usuario en un formulario firmado digitalmente.

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

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

El token ha sido firmado digitalmente usando una cadena de variable de entorno SECRET como secreto. La firma digital garantiza que solo las partes que conocen el secreto puedan generar un token válido. El valor de la variable de entorno debe establecerse en el archivo .env.

Una solicitud exitosa se responde con el código de estado 200 OK. El token generado y el nombre de usuario del usuario se devuelven al cuerpo de la respuesta.

Ahora, el código de inicio de sesión solo debe agregarse a la aplicación agregando el nuevo enrutador a app.js.

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

//...

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

Vamos a intentar logearnos usando el cliente REST de VS Code:

fullstack content

No funciona. Se imprime lo siguiente en la consola:

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

El comando _jwt.sign(userForToken, process.env.SECRET) _ falla. Olvidamos establecer un valor para la variable de entorno SECRET. Puede ser cualquier string. Cuando establecemos el valor en el archivo .env, el inicio de sesión funciona.

Un inicio de sesión exitoso devuelve los detalles del usuario y el token:

fullstack content

Un nombre de usuario o contraseña incorrectos devuelve un mensaje de error y el código de estado correcto:

fullstack content

Limitación de la creación de nuevas notas a los usuarios registrados

Cambiemos la creación de nuevas notas para que solo sea posible si la solicitud de publicación tiene un token válido adjunto. Luego, la nota se guarda en la lista de notas del usuario identificado por el token.

Hay varias formas de enviar el token desde el navegador al servidor. Usaremos el encabezado Authorization. El encabezado también indica qué esquema de autenticación se utiliza. Esto puede ser necesario si el servidor ofrece varias formas de autenticación. La identificación del esquema le dice al servidor cómo se deben interpretar las credenciales adjuntas.

El esquema Bearer se adapta a nuestras necesidades.

En la práctica, esto significa que si el token es, por ejemplo, la cadena eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, el encabezado de autorización tendrá el valor:


Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW

Creación de nuevas notas cambiará de este modo:

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

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

  response.json(savedNote)
})

La función auxiliar getTokenFrom aísla el token del encabezado de authorization. La validez del token se comprueba con jwt.verify. El método también decodifica el token, o devuelve el objeto en el que se basó el token:

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

El objeto decodificado del token contiene los campos username y id, que le dice al servidor quién hizo la solicitud.

Si no hay ningún token, o el objeto decodificado del token no contiene la identidad del usuario (decodedToken.id no está definido), el código de estado de error 401 unauthorized es devuelto y el motivo del error se explica en el cuerpo de la respuesta.

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

Cuando se resuelve la identidad del autor de la solicitud, la ejecución continúa como antes.

Ahora se puede crear una nueva nota usando Postman si el encabezado authorization tiene el valor correcto, la cadena bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, donde el segundo valor es el token devuelto por la operación iniciar sesión.

Usando Postman, esto se ve de la siguiente manera:

fullstack content

y con el cliente REST de Visual Studio Code

fullstack content

Manejo de errores

La verificación del token también puede causar un JsonWebTokenError. Si, por ejemplo, eliminamos algunos caracteres del token e intentamos crear una nueva nota, esto sucede:

JsonWebTokenError: invalid signature
    at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19
    at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14)
    at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10)
    at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30)

Hay muchas razones posibles para un error de decodificación. El token puede ser defectuoso (como en nuestro ejemplo), falsificado o vencido. Extendamos nuestro middleware errorHandler para tener en cuenta los diferentes errores de decodificación.

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

const errorHandler = (error, request, response, next) => {
  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'    })  }

  logger.error(error.message)

  next(error)
}

El código de la aplicación actual se puede encontrar en Github, rama part4-9.

Si la aplicación tiene múltiples interfaces que requieren identificación, la validación de JWT debe separarse en su propio middleware. También se podría utilizar alguna librería existente como express-jwt.

Notas finales

Ha habido muchos cambios en el código que han causado un problema típico para un proyecto de software de ritmo rápido: la mayoría de las pruebas no funcionan. Debido a que esta parte del curso ya está repleta de nueva información, dejaremos la fijación de las pruebas a un ejercicio no obligatorio.

Los nombres de usuario, las contraseñas y las aplicaciones que utilizan la autenticación de token siempre deben usarse en HTTPS. Podríamos usar un servidor Node HTTPS en nuestra aplicación en lugar del HTTP servidor (requiere más configuración). Por otro lado, la versión de producción de nuestra aplicación está en Heroku, por lo que nuestra aplicación permanece segura: Heroku enruta todo el tráfico entre un navegador y el servidor Heroku a través de HTTPS.

Implementaremos el inicio de sesión en la interfaz en la siguiente parte.