Como criar uma API REST segura no Node.js

Publicados: 2022-03-11

As interfaces de programação de aplicativos (APIs) estão em toda parte. Eles permitem que o software se comunique com outros softwares – internos ou externos – de forma consistente, o que é um ingrediente-chave na escalabilidade, sem mencionar a reutilização.

É bastante comum hoje em dia que os serviços online tenham APIs voltadas para o público. Isso permite que outros desenvolvedores integrem facilmente recursos como logins de mídia social, pagamentos com cartão de crédito e rastreamento de comportamento. O padrão de fato que eles usam para isso é chamado de REpresentational State Transfer (REST).

Embora uma infinidade de plataformas e linguagens de programação possam ser usadas para a tarefa - por exemplo, ASP.NET Core, Laravel (PHP) ou Bottle (Python) - neste tutorial, criaremos um back-end de API REST básico, mas seguro, usando a seguinte pilha:

  • Node.js, com o qual o leitor já deve ter alguma familiaridade
  • Express, que simplifica muito a criação de tarefas comuns de servidor da Web no Node.js e é padrão na criação de um back-end de API REST
  • Mongoose, que conectará nosso back-end a um banco de dados MongoDB

Os desenvolvedores que seguem este tutorial também devem estar familiarizados com o terminal (ou prompt de comando).

Observação: não abordaremos uma base de código de front-end aqui, mas o fato de nosso back-end ser escrito em JavaScript torna conveniente compartilhar código - modelos de objeto, por exemplo - em toda a pilha.

Anatomia de uma API REST

As APIs REST são usadas para acessar e manipular dados usando um conjunto comum de operações sem estado. Essas operações são parte integrante do protocolo HTTP e representam a funcionalidade essencial de criar, ler, atualizar e excluir (CRUD), embora não de uma maneira limpa de um para um:

  • POST (criar um recurso ou geralmente fornecer dados)
  • GET (recuperar um índice de recursos ou um recurso individual)
  • PUT (criar ou substituir um recurso)
  • PATCH (atualizar/modificar um recurso)
  • DELETE (remover um recurso)

Usando essas operações HTTP e um nome de recurso como endereço, podemos construir uma API REST criando um endpoint para cada operação. E ao implementar o padrão, teremos uma base estável e facilmente compreensível, permitindo-nos evoluir o código rapidamente e mantê-lo posteriormente. Como mencionado anteriormente, a mesma base será usada para integrar recursos de terceiros, a maioria dos quais também usa APIs REST, tornando essa integração mais rápida.

Por enquanto, vamos começar a criar nossa API REST segura usando Node.js!

Neste tutorial, vamos criar uma API REST bastante comum (e muito prática) para um recurso chamado users .

Nosso recurso terá a seguinte estrutura básica:

  • id (um UUID gerado automaticamente)
  • firstName
  • lastName
  • email
  • password
  • permissionLevel (o que este usuário tem permissão para fazer?)

E vamos criar as seguintes operações para esse recurso:

  • POST no endpoint /users (crie um novo usuário)
  • GET no endpoint /users (listar todos os usuários)
  • GET no endpoint /users/:userId (obtenha um usuário específico)
  • PATCH no endpoint /users/:userId (atualize os dados para um usuário específico)
  • DELETE no endpoint /users/:userId (remover um usuário específico)

Também usaremos tokens da Web JSON (JWTs) para tokens de acesso. Para isso, criaremos outro recurso chamado auth que esperará o email e a senha de um usuário e, em troca, gerará o token usado para autenticação em determinadas operações. (O ótimo artigo de Dejan Milosevic sobre JWT para aplicativos REST seguros em Java entra em mais detalhes sobre isso; os princípios são os mesmos.)

Configuração do tutorial da API REST

Em primeiro lugar, certifique-se de ter a versão mais recente do Node.js instalada. Para este artigo, usarei a versão 14.9.0; também pode funcionar em versões mais antigas.

Em seguida, certifique-se de ter o MongoDB instalado. Não explicaremos os detalhes do Mongoose e do MongoDB que são usados ​​aqui, mas para executar o básico, basta iniciar o servidor no modo interativo (ou seja, a partir da linha de comando como mongo ) em vez de como um serviço. Isso porque, em um ponto deste tutorial, precisaremos interagir com o MongoDB diretamente em vez de por meio de nosso código Node.js.

Observação: com o MongoDB, não há necessidade de criar um banco de dados específico, como pode haver em alguns cenários de RDBMS. A primeira chamada de inserção do nosso código Node.js acionará sua criação automaticamente.

Este tutorial não contém todo o código necessário para um projeto funcional. Em vez disso, você clona o repositório complementar e simplesmente segue os destaques enquanto lê, mas também pode copiar arquivos e trechos específicos do repositório conforme necessário, se preferir.

Navegue até a pasta rest-api-tutorial/ resultante em seu terminal. Você verá que nosso projeto contém três pastas de módulos:

  • common (manipulação de todos os serviços compartilhados e informações compartilhadas entre os módulos do usuário)
  • users (tudo sobre usuários)
  • auth (lidando com a geração de JWT e o fluxo de login)

Agora, execute npm install (ou yarn se você tiver).

Parabéns, agora você tem todas as dependências e configurações necessárias para executar nosso back-end simples da API REST.

Criando o módulo de usuário

Usaremos Mongoose, uma biblioteca de modelagem de dados de objeto (ODM) para MongoDB, para criar o modelo de usuário dentro do esquema de usuário.

Primeiro, precisamos criar o esquema do Mongoose em /users/models/users.model.js :

 const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });

Uma vez que definimos o esquema, podemos facilmente anexar o esquema ao modelo do usuário.

 const userModel = mongoose.model('Users', userSchema);

Depois disso, podemos usar esse modelo para implementar todas as operações CRUD que queremos em nossos endpoints Express.

Vamos começar com a operação “criar usuário” definindo a rota em users/routes.config.js :

 app.post('/users', [ UsersController.insert ]);

Isso é colocado em nosso aplicativo Express no arquivo index.js principal. O objeto UsersController é importado do nosso controller, onde fazemos o hash da senha apropriadamente, definida em /users/controllers/users.controller.js :

 exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };

Neste ponto, podemos testar nosso modelo Mongoose executando o servidor ( npm start ) e enviando uma solicitação POST para /users com alguns dados JSON:

 { "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd" }

Existem várias ferramentas que você pode usar para isso. Insomnia (abordado abaixo) e Postman são ferramentas GUI populares, e curl é uma escolha comum de CLI. Você pode até mesmo usar JavaScript, por exemplo, do console de ferramentas de desenvolvimento integrado do seu navegador:

 fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });

Neste ponto, o resultado de uma postagem válida será apenas o id do usuário criado: { "id": "5b02c5c84817bf28049e58a3" } . Precisamos também adicionar o método createUser ao modelo em users/models/users.model.js :

 exports.createUser = (userData) => { const user = new User(userData); return user.save(); };

Tudo pronto, agora precisamos ver se o usuário existe. Para isso, vamos implementar o recurso “get user by id” para o seguinte endpoint: users/:userId .

Primeiro, criamos uma rota em /users/routes/config.js :

 app.get('/users/:userId', [ UsersController.getById ]);

Em seguida, criamos o controlador em /users/controllers/users.controller.js :

 exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };

E, finalmente, adicione o método findById ao modelo em /users/models/users.model.js :

 exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };

A resposta será assim:

 { "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }

Observe que podemos ver a senha com hash. Para este tutorial, estamos mostrando a senha, mas a melhor prática óbvia é nunca revelar a senha, mesmo que ela tenha sido hash. Outra coisa que podemos ver é o permissionLevel , que usaremos para lidar com as permissões do usuário posteriormente.

Repetindo o padrão apresentado acima, agora podemos adicionar a funcionalidade para atualizar o usuário. Usaremos a operação PATCH , pois ela nos permitirá enviar apenas os campos que desejamos alterar. A rota será, portanto, PATCH para /users/:userid , e enviaremos todos os campos que desejamos alterar. Também precisaremos implementar alguma validação extra, pois as alterações devem ser restritas ao usuário em questão ou a um administrador, e apenas um administrador deve poder alterar o permissionLevel . Vamos pular isso por enquanto e voltar a ele assim que implementarmos o módulo de autenticação. Por enquanto, nosso controlador ficará assim:

 exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };

Por padrão, enviaremos um código HTTP 204 sem corpo de resposta para indicar que a solicitação foi bem-sucedida.

E precisaremos adicionar o método patchUser ao modelo:

 exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };

A lista de usuários será implementada como um GET em /users/ pelo seguinte controlador:

 exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };

O método de modelo correspondente será:

 exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };

A resposta da lista resultante terá a seguinte estrutura:

 [ { "firstName": "Marco", "lastName": "Silva", "email": "[email protected]", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "[email protected]", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]

E a última parte a ser implementada é o DELETE em /users/:userId .

Nosso controlador para exclusão será:

 exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };

Da mesma forma que antes, o controlador retornará o código HTTP 204 e nenhum corpo de conteúdo como confirmação.

O método de modelo correspondente deve ficar assim:

 exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };

Agora temos todas as operações necessárias para manipular o recurso de usuário e terminamos com o controlador de usuário. A ideia principal deste código é fornecer os conceitos básicos de uso do padrão REST. Precisaremos retornar a esse código para implementar algumas validações e permissões para ele, mas primeiro precisaremos começar a construir nossa segurança. Vamos criar o módulo de autenticação.

Criando o módulo de autenticação

Antes de podermos proteger o módulo de users implementando o middleware de permissão e validação, precisaremos gerar um token válido para o usuário atual. Geraremos um JWT em resposta ao usuário fornecer um e-mail e senha válidos. O JWT é um token da Web JSON notável que você pode usar para que o usuário faça várias solicitações com segurança sem validar repetidamente. Geralmente tem um tempo de expiração e um novo token é recriado a cada poucos minutos para manter a comunicação segura. Para este tutorial, porém, deixaremos de atualizar o token e o manteremos simples com um único token por login.

Primeiro, criaremos um endpoint para solicitações POST para o recurso /auth . O corpo da solicitação conterá o e-mail e a senha do usuário:

 { "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd2" }

Antes de envolver o controlador, devemos validar o usuário em /authorization/middlewares/verify.user.middleware.js :

 exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };

Feito isso, podemos passar para o controlador e gerar o JWT:

 exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };

Embora não estejamos atualizando o token neste tutorial, o controlador foi configurado para permitir essa geração para facilitar a implementação no desenvolvimento subsequente.

Tudo o que precisamos agora é criar a rota e invocar o middleware apropriado em /authorization/routes.config.js :

 app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);

A resposta conterá o JWT gerado no campo accessToken:

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }

Tendo criado o token, podemos usá-lo dentro do cabeçalho Authorization usando o formulário Bearer ACCESS_TOKEN .

Criação de middleware de permissões e validações

A primeira coisa que devemos definir é quem pode usar o recurso de users . Estes são os cenários que precisaremos lidar:

  • Público para criação de usuários (processo de registro). Não usaremos JWT para este cenário.
  • Privado para o usuário conectado e para que os administradores atualizem esse usuário.
  • Privado apenas para administrador para remover contas de usuário.

Tendo identificado esses cenários, primeiro exigiremos um middleware que sempre valide o usuário se ele estiver usando um JWT válido. O middleware em /common/middlewares/auth.validation.middleware.js pode ser tão simples quanto:

 exports.validJWTNeeded = (req, res, next) => { if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };

Usaremos códigos de erro HTTP para lidar com erros de solicitação:

  • HTTP 401 para uma solicitação inválida
  • HTTP 403 para uma solicitação válida com um token inválido ou um token válido com permissões inválidas

Podemos usar o operador AND bit a bit (bitmasking) para controlar as permissões. Se definirmos cada permissão necessária como uma potência de 2, podemos tratar cada bit do inteiro de 32 bits como uma única permissão. Um administrador pode então ter todas as permissões definindo seu valor de permissão para 2147483647. Esse usuário pode então ter acesso a qualquer rota. Como outro exemplo, um usuário cujo valor de permissão foi definido como 7 teria permissões para as funções marcadas com bits para os valores 1, 2 e 4 (dois à potência de 0, 1 e 2).

O middleware para isso ficaria assim:

 exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };

O middleware é genérico. Se o nível de permissão do usuário e o nível de permissão necessário coincidirem em pelo menos um bit, o resultado será maior que zero, e podemos deixar a ação prosseguir; caso contrário, o código HTTP 403 será retornado.

Agora, precisamos adicionar o middleware de autenticação às rotas do módulo do usuário em /users/routes.config.js :

 app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);

Isso conclui o desenvolvimento básico de nossa API REST. Tudo o que resta a ser feito é testar tudo.

Correndo e testando com insônia

O Insomnia é um cliente REST decente com uma boa versão gratuita. A prática recomendada é, obviamente, incluir testes de código e implementar relatórios de erros adequados no projeto, mas os clientes REST de terceiros são ótimos para testar e implementar soluções de terceiros quando o relatório de erros e a depuração do serviço não estão disponíveis. Nós o usaremos aqui para desempenhar o papel de um aplicativo e obter algumas informações sobre o que está acontecendo com nossa API.

Para criar um usuário, basta POST os campos obrigatórios para o endpoint apropriado e armazenar o ID gerado para uso posterior.

Solicitação com os dados apropriados para a criação de um usuário

A API responderá com o ID do usuário:

Resposta de confirmação com userID

Agora podemos gerar o JWT usando o endpoint /auth/ :

Solicitar com dados de login

Devemos obter um token como nossa resposta:

Confirmação contendo o token Web JSON correspondente

Pegue o accessToken , prefixe-o com Bearer (lembre-se do espaço) e adicione-o aos cabeçalhos da solicitação em Authorization :

A configuração dos cabeçalhos a serem transferidos contém o JWT de autenticação

Se não fizermos isso agora que implementamos o middleware de permissões, todas as solicitações, exceto o registro, retornarão o código HTTP 401. Com o token válido, obteremos a seguinte resposta de /users/:userId :

Resposta listando os dados do usuário indicado

Além disso, como mencionado anteriormente, estamos exibindo todos os campos, para fins educacionais e de simplicidade. A senha (com hash ou não) nunca deve ser visível na resposta.

Vamos tentar obter uma lista de usuários:

Solicitar uma lista de todos os usuários

Surpresa! Recebemos uma resposta 403.

Ação recusada devido à falta de nível de permissão apropriado

Nosso usuário não tem permissão para acessar este endpoint. Precisaremos alterar o permissionLevel de nosso usuário de 1 para 7 (ou até 5 faria, já que nossos níveis de permissões gratuitas e pagas são representados como 1 e 4, respectivamente). Podemos fazer isso manualmente no MongoDB, em seu prompt interativo , assim (com o ID alterado para o seu resultado local):

 db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})

Então, precisamos gerar um novo JWT.

Feito isso, obtemos a resposta adequada:

Resposta com todos os usuários e seus dados

Em seguida, vamos testar a funcionalidade de atualização enviando uma solicitação PATCH com alguns campos para nosso endpoint /users/:userId :

Solicitação contendo dados parciais a serem atualizados

Esperamos uma resposta 204 como confirmação de uma operação bem-sucedida, mas podemos solicitar que o usuário verifique novamente.

Resposta após mudança bem-sucedida

Por fim, precisamos excluir o usuário. Precisaremos criar um novo usuário conforme descrito acima (não se esqueça de anotar o ID do usuário) e certificar-se de que temos o JWT apropriado para um usuário administrador. O novo usuário precisará de suas permissões definidas para 2053 (ou seja, 2048— ADMIN — mais nossas 5 anteriores) para poder também executar a operação de exclusão. Feito isso e gerado um novo JWT, teremos que atualizar nosso cabeçalho de solicitação de Authorization :

Solicitar configuração para excluir um usuário

Enviando uma solicitação DELETE para /users/:userId , devemos obter uma resposta 204 como confirmação. Podemos, novamente, verificar solicitando que /users/ liste todos os usuários existentes.

Próximas etapas para sua API REST

Com as ferramentas e métodos abordados neste tutorial, agora você poderá criar APIs REST simples e seguras no Node.js. Muitas das melhores práticas que não são essenciais para o processo foram ignoradas, então não se esqueça de:

  • Implemente validações adequadas (por exemplo, certifique-se de que o e-mail do usuário seja exclusivo)
  • Implementar testes de unidade e relatórios de erros
  • Impedir que os usuários alterem seu próprio nível de permissão
  • Impedir que os administradores se removam
  • Impedir a divulgação de informações confidenciais (por exemplo, senhas com hash)
  • Mova o segredo JWT de common/config/env.config.js para um mecanismo de distribuição de segredo fora do repositório e não baseado em ambiente

Um exercício final para o leitor pode ser converter a base de código de seu uso de promessas JavaScript para a técnica async/await.

Para aqueles que possam estar interessados, agora também existe uma versão TypeScript do projeto disponível.

Relacionado: 5 coisas que você nunca fez com uma especificação REST