d
Autenticação por token
Os usuários devem estar aptos a logarem na aplicação, e quando um usuário é logado, suas informações devem ser adicionadas automaticamente a qualquer nova nota que você criar.
Agora nós vamos implementar no backend uma autenticação baseada em token.
Os princípios da autenticação baseada em token são apresentados no diagrama abaixo:
-
Usuário começa a logar usando uma formulário de login implementado com React
- Nós adicionamos o formulário no front-end ns part 5
- O React code envia o nome de usuário e a senha para o endereço /api/login do servidor como uma requisição HTTP POST
-
Se o usuário e a senha estiverem corretos, o servidor gera um token que de alguma forma identifica o usuário logado.
- O token é assinado digitalmente, tornando-o impossível de falsificar (no sentido criptográfico)
- O backend responde com um código de status de operação bem sucedida e retorna o token com a resposta.
- O browser salva o token, como por exemplo em um estado (state) de uma aplicação React.
- Quando o usuário cria uma nova nota (ou outra operação que requer identificação), o código React envia o token para o servidor com a requisição.
- O servidor usa o token para identificar o usuário.
Vamos implementar a funcionalidade de login. Instale o jsonwebtoken biblioteca, que permite gerar JSON web tokens.
npm install jsonwebtoken
O código para a funcionalidade de login está no arquivo 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
O código inicia buscando o usuário no banco de dados pelo username anexado à requisição. Em seguida, verifica a senha, também anexada à requisição. Como as senhas não são armazenadas no banco de dados, mas sim hashes calculados a partir das senhas, o método bcrypt.compare é usado para comparar se a senha está correta:
await bcrypt.compare(body.password, user.passwordHash)
Se o usuário não for encontrado ou se a senha estiver incorreta, a requisição é respondida com o código de status 401 Unauthorized. O motivo para a falha é explicado no corpo da resposta.
Se a senha estiver correta, o token é gerado com o método jwt.sign. O token contém o nome de usuário e o ID do usuário assinado digitalmente.
const userForToken = {
username: user.username,
id: user._id,
}
const token = jwt.sign(userForToken, process.env.SECRET)
O token é assinado digitalmente utilizando-se uma string passada pela variável de ambiente SECRET como o segredo. A assinatura digital garante que somente as partes que conhecem o segredo podem gerar o token válido. O valor da variável de ambiente deve ser definido no arquivo env.
Uma requisição bem sucedida é respondida com o código de status 200 ok. O token e o username do usuários são enviados de volta no corpo da resposta.
Agora basta adicionar o código do login na aplicação, por meio de uma nova rota no app.js
const loginRouter = require('./controllers/login')
//...
app.use('/api/login', loginRouter)
Vamos tentar logar usando o VS Code REST-client:
Se não funcionar, a seguinte mensagem vai aparecer no console:
(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)
O comando jwt.sign(userForToken, process.env.SECRET) falhou. Nós esquecemos de definir um valor para a variável de ambiente SECRET. Pode ser qualquer string. Quando definimos o valor no arquivo env, (e reiniciamos o servidor), o login funcionará.
Um login bem-sucedido retorna os detalhes do usuário e o token:
Um nome de usuário ou senha incorretos retorna uma messagem de erro e o código de status apropriado
Limitando a criação de novas notas para somente usuários logados
Vamos modificar a criação de novas notas de forma que somente seja possível se a requisição post contiver um token válido. A nota será salva na lista de notas do usuário identificado pelo token.
Existem diversas formas de enviar o token do navegador para o servidor. Nós vamos utilizar o cabeçalho (header) Authorization. O header também informa qual é o esquema de autenticação utilizado. Isso pode ser necessário se o servidor oferece múltiplas formas de autenticação. Identificar o esquema revela ao servidor como as credenciais anexadas devem ser interpretadas.
O esquema Bearer é adequado às nossas necessidades.
Na prática, significa que se o token é a string eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, por exemplo, o header de autorização (Authorization) terá o seguinte valor:
Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW
O código para criação de novas notas deve ser alterado assim:
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)
})
A função auxiliar getTokenFrom isola o token do header authorization. A validade do token é checada com jwt.verify. O método também decodifica o token, ou retorna o objeto no qual o token foi baseado.
const decodedToken = jwt.verify(token, process.env.SECRET)
Se o token estiver faltando ou for inválido, a exceção JsonWebTokenError será lançada. Precisamos expandir o tratamento de erros do middleware para que também cuide desse caso em particular:
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 === 'JsonWebTokenError') { return response.status(400).json({ error: error.message }) }
next(error)
}
O objeto decodificado do token contém os campos username e id, os quais informar ao servidor quem fez a requisição.
Se o objeto decodificado do token não contiver a identidade do usuário (decodedToken.id é undefined), o código de status 401 unauthorized será retornado e o motivo da falha será explicado no corpo da resposta.
if (!decodedToken.id) {
return response.status(401).json({
error: 'token invalid'
})
}
Se a identidade de quem fez a requisição for resolvida, a execução continuará como antes.
Uma nova nota pode agora ser criada utilizando o Postman se o header authorization fornecer o valor correto com a string Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, onde o segundo valor é o token retornado pela operação login.
Utilizando o Postman, seria assim:
já com o Visual Studio Code REST client:
A aplicação atual pode ser encontrada no Github, branch part4-9.
Se a aplicação possuir múltiplas interfaces requerendo identificação, a validação pelo JWT deve estar separada em seus próprio middleware. Uma biblioteca já existente também pode ser utilizada, como a express-jwt.
O problema da autenticação baseada em token
Autenticação por token é muito fácil de implementar, mas possui um problema. Uma vez que o usuário da API, por exemplo um app React, obtém um token, a API confiará cegamente nele. E se o direito de acesso do token precisar ser revogado?
Há duas soluções para o problema. A mais fácil delas é limitar o período de validade do 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,
}
// o token expira em uma hora (60*60 segundos)
const token = jwt.sign( userForToken, process.env.SECRET, { expiresIn: 60*60 } )
response
.status(200)
.send({ token, username: user.username, name: user.name })
})
Uma vez expirado, o app cliente necessitará de um novo token. Normalmente, isso acontece forçando o usuário a logar novamente no app.
O tratamento de erro do middleware deve ser expandido para fornecer o erro apropriado no caso de um token expirado:
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 === 'JsonWebTokenError') {
return response.status(401).json({
error: 'invalid token'
})
} else if (error.name === 'TokenExpiredError') { return response.status(401).json({ error: 'token expired' }) }
next(error)
}
Quanto mais curto o tempo de expiração, mais segura é a solução. Portanto, se o token cair em mãos erradas ou se o acesso do usuário ao sistema precisar ser revogado, o token só poderá ser usado por um período limitado de tempo. Por outro lado, um tempo de expiração curto força o usuário a fazer login no sistema com mais frequência.
A outra solução é salvar informações sobre cada token no banco de dados do backend e verificar se o direito de acesso correspondente ao token ainda é válido para cada solicitação da API. Com esse esquema, os direitos de acesso podem ser revogados a qualquer momento. Esse tipo de solução é frequentemente chamado de sessão do lado do servidor (server-side session).
O aspecto negativo das sessões do lado do servidor é o aumento da complexidade no backend e também o efeito na performance, uma vez que a validade do token precisa ser verificada para cada solicitação da API ao banco de dados. O acesso ao banco de dados é consideravelmente mais lento em comparação com a verificação da validade do próprio token. É por isso que é bastante comum salvar a sessão correspondente a um token em um banco de dados chave-valor como [Redis] (https://redis.io/), que é limitado em funcionalidade em comparação, por exemplo, ao MongoDB ou um banco de dados relacional, mas extremamente rápido em alguns cenários de uso.
Quando as sessões do lado do servidor são usadas, o token geralmente é apenas uma string aleatória que não inclui nenhuma informação sobre o usuário, como geralmente é o caso quando os tokens jwt são usados. Para cada solicitação da API, o servidor busca as informações relevantes sobre a identidade do usuário no banco de dados. Também é bastante comum que, em vez de usar o cabeçalho (header) Authorization, sejam usados cookies como mecanismo para transferir o token entre o cliente e o servidor.
Notas finais
Foram realizadas muitas alterações no código que causaram um problema típico em projetos de software de ritmo acelerado: a maioria dos testes quebrou. Porque esta parte do curso já está cheia de informações novas, deixaremos a correção dos testes como um exercício não obrigatório.
Nomes de usuário, senhas e aplicações que usam autenticação por token devem sempre ser usados por meio de [HTTPS] (https://en.wikipedia.org/wiki/HTTPS). Poderíamos usar um servidor Node [HTTPS] (https://pt.wikipedia.org/wiki/Hyper_Text_Transfer_Protocol_Secure) em nossa aplicação em vez do servidor [HTTP] (https://nodejs.org/docs/latest-v8.x/api/http.html) (ele requer mais configuração). Por outro lado, a versão de produção de nossa aplicação está no Heroku, portanto, nossa aplicação permanece segura: o Heroku direciona todo o tráfego entre um navegador e o servidor Heroku através de HTTPS.
Vamos implementar o login no frontend na próxima parte.