Saltar al contenido

d

Autenticación basada en 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:

diagrama de secuencia de autenticación basada en tokens
  • 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 la 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. Instala 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 { 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

El código comienza buscando al usuario en la base de datos por el username adjunto a la solicitud.

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

A continuación, verifica la password, también adjunta a la solicitud.

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

Debido a que las contraseñas en sí no se guardan en la base de datos, sino que se guardan hashes calculados a partir de las contraseñas, el método bcrypt.compare se usa para verificar si la contraseña es correcta:

await bcrypt.compare(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 la respuesta.

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

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 ID de usuario en un formato 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 el 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 username del usuario se devuelven al cuerpo de la respuesta.

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

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:

vscode rest post a api/login con username/password

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:

vs code rest, respuesta mostrando detalles y token

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

vs code rest, respuesta para credenciales de login incorrectos

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 (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 función auxiliar getTokenFrom aísla el token del encabezado 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)

Si el token es invalido o está ausente, la excepción JsonWebTokenError es generada. Tenemos que extender nuestro middleware de manejo de errors para tener en cuenta este caso particular:

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 === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) {
    return response.status(400).json({ error: 'expected `username` to be unique' })
  } else if (error.name ===  'JsonWebTokenError') {    return response.status(401).json({ error: 'token invalid' })  }

  next(error)
}

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

Si el objeto decodificado del token no contiene la identidad del usuario (decodedToken.id es undefined), 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 (!decodedToken.id) {
  return response.status(401).json({
    error: 'token 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 login.

Usando Postman, esto se ve de la siguiente manera:

postman agregando bearer token

y con el cliente REST de Visual Studio Code

vscode rest agregando bearer token

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.

Problemas de la autenticación basada en Tokens

La autenticación basada en tokens es muy fácil de implementar, pero tiene un problema. Una vez que el cliente de la API, por ejemplo una aplicación React, obtiene un token, la API tiene una confianza ciega en el titular del token. ¿Qué sucede si necesitamos revocar los derechos de acceso del titular del token?

Hay dos soluciones al problema. La más fácil es limitar el período de validez de un token:

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

  // el token expira in 60*60 segundos, eso es, en una hora
  const token = jwt.sign(    userForToken,     process.env.SECRET,    { expiresIn: 60*60 }  )
  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

Una vez que el token caduca, la aplicación cliente necesita obtener un nuevo token. Por lo general, esto sucede al obligar al usuario a volver a iniciar sesión en la aplicación.

El middleware de manejo de errores debe extenderse para dar un error adecuado en el caso de un token caducado:

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 === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) {
    return response.status(400).json({
      error: 'expected `username` to be unique'
    })
  } 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)
}

Cuanto más corto sea el tiempo de caducidad, más segura será la solución. Por lo tanto, si el token cae en las manos equivocadas o es necesario revocar el acceso del usuario al sistema, el token solo se puede utilizar durante un período de tiempo limitado. Por otro lado, un tiempo de caducidad corto genera un dolor potencial para el usuario, ya que implica iniciar sesión en el sistema con más frecuencia.

La otra solución es guardar información sobre cada token en la base de datos y verificar en cada solicitud de API si el derecho de acceso correspondiente al token sigue siendo válido. Con este esquema, los derechos de acceso pueden ser revocados en cualquier momento. Este tipo de solución a menudo se denomina server-side session.

El aspecto negativo de las sesiones del lado del servidor es la mayor complejidad en el backend y también el efecto en el rendimiento, ya que se debe verificar la validez del token para cada solicitud de API a la base de datos. El acceso a la base de datos es considerablemente más lento en comparación con la verificación de la validez del token en sí. Es por eso que es bastante común guardar la sesión correspondiente a un token en una base de datos de llave-valor como Redis, que tiene una funcionalidad limitada en comparación con MongoDB o una base de datos relacional, pero es extremadamente rápida en algunos escenarios de uso.

Cuando se utilizan sesiones del lado del servidor, el token suele ser solo una cadena aleatoria, que no incluye ninguna información sobre el usuario, como suele ser el caso cuando se utilizan jwt-tokens. Para cada solicitud de API, el servidor obtiene la información relevante sobre la identidad del usuario de la base de datos. También es bastante habitual que, en lugar de utilizar el encabezado de autorización, se utilicen cookies como mecanismo para transferir el token entre el cliente y el servidor.

Notas finales

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

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

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

NOTA: En esta etapa, en la aplicación de notas desplegada, se espera que la función de crear una nota deje de funcionar ya que la funcionalidad de inicio de sesión del backend aún no está vinculada al frontend.