Projeto: API REST com autenticação JWT

[114] Projeto: API REST com autenticação JWT

O Módulo 4 fecha juntando Node, Express, MongoDB e Mongoose numa API de tarefas com cadastro, login e rotas protegidas. Senha com bcrypt, sessão por JWT, cada tarefa amarrada ao dono, paginação, filtros, estatísticas por agregação e um tratador que converte os erros do Mongoose em status HTTP.
Javascript

21 min de leitura

Chegamos ao fim do Módulo 4. Em seis artigos você saiu do zero no Node.js até construir APIs com banco de dados real. Agora vamos revisar tudo e construir o projeto final do módulo — uma API REST completa com autenticação JWT, unindo Node, Express, MongoDB e Mongoose em uma aplicação profissional.

Revisão rápida — Módulo 4

Node.js

// Ambiente de execução JavaScript fora do navegador
// Motor V8 — mesmo do Chrome
// Acesso ao sistema operacional via módulos nativos

const fs = require("fs/promises");
const path = require("path");
const os = require("os");

// process — ponte com o sistema
process.env.PORT;          // variáveis de ambiente
process.argv.slice(2);     // argumentos da CLI
process.exit(1);           // encerra com erro

// Módulos: CommonJS (require) ou ES Modules (import)

NPM e package.json

{
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  },
  "dependencies": { "express": "^4.18.2" },
  "devDependencies": { "nodemon": "^3.0.2" }
}
npm install          # instala dependências
npm ci               # instala versões exatas (produção/CI)
npm run dev          # executa script "dev"
npm audit            # verifica vulnerabilidades
# Nunca commite node_modules — sempre commite package-lock.json

Servidor HTTP e Express

// Express abstrai o módulo http
const express = require("express");
const app = express();

app.use(express.json());          // parse de JSON
app.use(logger);                  // middleware customizado

app.get("/rota/:id", handler);    // rota com parâmetro
app.post("/rota", handler);       // POST

// Error handler — sempre 4 parâmetros
app.use((erro, req, res, next) => {
  res.status(500).json({ erro: erro.message });
});

app.listen(3000);

MongoDB e Mongoose

// Schema → Model → Operações CRUD
const schema = new Schema({ nome: { type: String, required: true } });
const Model = mongoose.model("Nome", schema);

await Model.create(dados);
await Model.find(filtros).sort("-createdAt").limit(10);
await Model.findByIdAndUpdate(id, { $set: dados }, { new: true });
await Model.findByIdAndDelete(id);

// Índices, virtuals, hooks (pre/post), métodos, populate

O projeto — API REST com JWT

Vamos construir uma API de gerenciamento de tarefas com:

  • Cadastro e login de usuários com hash de senha (bcrypt)
  • Autenticação via JWT (JSON Web Token)
  • CRUD de tarefas vinculadas ao usuário logado
  • Proteção de rotas por token
  • Paginação e filtros
  • Tratamento completo de erros

Estrutura do projeto

src/
├── config/
│   └── database.js
├── models/
│   ├── Usuario.js
│   └── Tarefa.js
├── middlewares/
│   ├── auth.js
│   ├── erros.js
│   └── logger.js
├── controllers/
│   ├── authController.js
│   └── tarefaController.js
├── routes/
│   ├── auth.js
│   └── tarefas.js
└── index.js
npm init -y
npm install express mongoose dotenv bcrypt jsonwebtoken
npm install -D nodemon
# .env
PORT=3000
NODE_ENV=development
MONGODB_URL=mongodb://localhost:27017/tarefas-api
JWT_SECRET=sua-chave-secreta-muito-longa-e-aleatoria-aqui
JWT_EXPIRA_EM=7d

Models

// src/models/Usuario.js
const mongoose = require("mongoose");
const bcrypt = require("bcrypt");

const usuarioSchema = new mongoose.Schema(
  {
    nome: {
      type: String,
      required: [true, "Nome é obrigatório."],
      trim: true,
      minlength: [2, "Nome deve ter pelo menos 2 caracteres."],
    },
    email: {
      type: String,
      required: [true, "Email é obrigatório."],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, "Email inválido."],
    },
    senha: {
      type: String,
      required: [true, "Senha é obrigatória."],
      minlength: [6, "Senha deve ter pelo menos 6 caracteres."],
      select: false, // nunca retorna a senha
    },
    ativo: { type: Boolean, default: true },
  },
  { timestamps: true, versionKey: false }
);

// Hash da senha antes de salvar
usuarioSchema.pre("save", async function (next) {
  if (!this.isModified("senha")) return next();
  this.senha = await bcrypt.hash(this.senha, 12);
  next();
});

// Método para verificar senha
usuarioSchema.methods.verificarSenha = async function (senhaDigitada) {
  return bcrypt.compare(senhaDigitada, this.senha);
};

// Remove campos sensíveis da resposta JSON
usuarioSchema.methods.toJSON = function () {
  const obj = this.toObject();
  delete obj.senha;
  obj.id = obj._id;
  delete obj._id;
  return obj;
};

module.exports = mongoose.model("Usuario", usuarioSchema);
// src/models/Tarefa.js
const mongoose = require("mongoose");

const tarefaSchema = new mongoose.Schema(
  {
    titulo: {
      type: String,
      required: [true, "Título é obrigatório."],
      trim: true,
      maxlength: [200, "Título não pode exceder 200 caracteres."],
    },
    descricao: {
      type: String,
      trim: true,
      maxlength: [2000, "Descrição não pode exceder 2000 caracteres."],
      default: "",
    },
    status: {
      type: String,
      enum: {
        values: ["pendente", "em_progresso", "concluida", "cancelada"],
        message: "Status inválido.",
      },
      default: "pendente",
    },
    prioridade: {
      type: String,
      enum: ["baixa", "media", "alta"],
      default: "media",
    },
    prazo: {
      type: Date,
      default: null,
    },
    tags: [String],
    usuario: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Usuario",
      required: true,
    },
  },
  { timestamps: true, versionKey: false }
);

// Índices
tarefaSchema.index({ usuario: 1, status: 1 });
tarefaSchema.index({ usuario: 1, createdAt: -1 });

// Virtual — indica se está atrasada
tarefaSchema.virtual("atrasada").get(function () {
  if (!this.prazo || this.status === "concluida") return false;
  return new Date() > this.prazo;
});

tarefaSchema.set("toJSON", { virtuals: true });

module.exports = mongoose.model("Tarefa", tarefaSchema);

Middleware de autenticação JWT

// src/middlewares/auth.js
const jwt = require("jsonwebtoken");
const Usuario = require("../models/Usuario");

async function autenticar(req, res, next) {
  try {
    // 1. Extrai o token do header
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith("Bearer ")) {
      return res.status(401).json({ erro: "Token de autenticação ausente." });
    }

    const token = authHeader.split(" ")[1];

    // 2. Verifica e decodifica o token
    let payload;
    try {
      payload = jwt.verify(token, process.env.JWT_SECRET);
    } catch (erro) {
      if (erro.name === "TokenExpiredError") {
        return res.status(401).json({ erro: "Token expirado. Faça login novamente." });
      }
      return res.status(401).json({ erro: "Token inválido." });
    }

    // 3. Busca o usuário no banco
    const usuario = await Usuario.findById(payload.id);
    if (!usuario || !usuario.ativo) {
      return res.status(401).json({ erro: "Usuário não encontrado ou inativo." });
    }

    // 4. Anexa o usuário ao req
    req.usuario = usuario;
    next();

  } catch (erro) {
    next(erro);
  }
}

module.exports = { autenticar };

Controllers

// src/controllers/authController.js
const jwt = require("jsonwebtoken");
const Usuario = require("../models/Usuario");

function gerarToken(id) {
  return jwt.sign({ id }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRA_EM || "7d",
  });
}

// POST /auth/registrar
async function registrar(req, res, next) {
  try {
    const { nome, email, senha } = req.body;

    // Verifica se email já existe
    const existente = await Usuario.findOne({ email });
    if (existente) {
      return res.status(409).json({ erro: "Email já cadastrado." });
    }

    const usuario = await Usuario.create({ nome, email, senha });
    const token = gerarToken(usuario._id);

    res.status(201).json({
      mensagem: "Usuário criado com sucesso.",
      token,
      usuario,
    });
  } catch (erro) {
    next(erro);
  }
}

// POST /auth/login
async function login(req, res, next) {
  try {
    const { email, senha } = req.body;

    if (!email || !senha) {
      return res.status(400).json({ erro: "Email e senha são obrigatórios." });
    }

    // Busca o usuário com a senha (select: false no schema)
    const usuario = await Usuario.findOne({ email }).select("+senha");
    if (!usuario) {
      return res.status(401).json({ erro: "Credenciais inválidas." });
    }

    // Verifica a senha
    const senhaCorreta = await usuario.verificarSenha(senha);
    if (!senhaCorreta) {
      return res.status(401).json({ erro: "Credenciais inválidas." });
    }

    if (!usuario.ativo) {
      return res.status(401).json({ erro: "Conta desativada." });
    }

    const token = gerarToken(usuario._id);

    // Remove a senha antes de enviar
    usuario.senha = undefined;

    res.json({
      mensagem: "Login realizado com sucesso.",
      token,
      usuario,
    });
  } catch (erro) {
    next(erro);
  }
}

// GET /auth/eu
async function eu(req, res) {
  // req.usuario já foi preenchido pelo middleware autenticar
  res.json({ usuario: req.usuario });
}

// PATCH /auth/senha
async function alterarSenha(req, res, next) {
  try {
    const { senhaAtual, novaSenha } = req.body;

    if (!senhaAtual || !novaSenha) {
      return res.status(400).json({ erro: "senhaAtual e novaSenha são obrigatórias." });
    }

    if (novaSenha.length < 6) {
      return res.status(422).json({ erro: "Nova senha deve ter pelo menos 6 caracteres." });
    }

    const usuario = await Usuario.findById(req.usuario._id).select("+senha");
    const senhaCorreta = await usuario.verificarSenha(senhaAtual);

    if (!senhaCorreta) {
      return res.status(401).json({ erro: "Senha atual incorreta." });
    }

    usuario.senha = novaSenha; // o hook pre-save fará o hash
    await usuario.save();

    res.json({ mensagem: "Senha alterada com sucesso." });
  } catch (erro) {
    next(erro);
  }
}

module.exports = { registrar, login, eu, alterarSenha };
// src/controllers/tarefaController.js
const Tarefa = require("../models/Tarefa");
const mongoose = require("mongoose");

// GET /tarefas
async function listar(req, res, next) {
  try {
    const {
      status,
      prioridade,
      busca,
      pagina = 1,
      por_pagina = 10,
      ordenar = "-createdAt",
    } = req.query;

    // Filtro base — apenas tarefas do usuário logado
    const filtros = { usuario: req.usuario._id };

    if (status) filtros.status = status;
    if (prioridade) filtros.prioridade = prioridade;
    if (busca) {
      // Escapa os metacaracteres antes de montar a expressão. Sem isto, um
      // parêntese solto derruba a consulta, e um padrão como (a+)+$ faz o servidor
      // gastar minutos numa única busca — o ataque conhecido como ReDoS.
      const seguro = busca.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      filtros.titulo = { $regex: seguro, $options: "i" };
    }

    const [tarefas, total] = await Promise.all([
      Tarefa.find(filtros)
        .sort(ordenar)
        .skip((pagina - 1) * por_pagina)
        .limit(Number(por_pagina))
        .lean(),
      Tarefa.countDocuments(filtros),
    ]);

    res.json({
      dados: tarefas,
      paginacao: {
        total,
        pagina: Number(pagina),
        por_pagina: Number(por_pagina),
        total_paginas: Math.ceil(total / por_pagina),
      },
    });
  } catch (erro) {
    next(erro);
  }
}

// GET /tarefas/estatisticas
async function estatisticas(req, res, next) {
  try {
    const stats = await Tarefa.aggregate([
      { $match: { usuario: req.usuario._id } },
      {
        $group: {
          _id: "$status",
          total: { $sum: 1 },
          comPrazo: {
            $sum: { $cond: [{ $ne: ["$prazo", null] }, 1, 0] },
          },
        },
      },
      {
        $project: {
          status: "$_id",
          total: 1,
          comPrazo: 1,
          _id: 0,
        },
      },
    ]);

    const atrasadas = await Tarefa.countDocuments({
      usuario: req.usuario._id,
      prazo: { $lt: new Date() },
      status: { $nin: ["concluida", "cancelada"] },
    });

    res.json({ porStatus: stats, atrasadas });
  } catch (erro) {
    next(erro);
  }
}

// GET /tarefas/:id
async function buscarUma(req, res, next) {
  try {
    if (!mongoose.isValidObjectId(req.params.id)) {
      return res.status(400).json({ erro: "ID inválido." });
    }

    const tarefa = await Tarefa.findOne({
      _id: req.params.id,
      usuario: req.usuario._id, // garante que é do usuário logado
    });

    if (!tarefa) {
      return res.status(404).json({ erro: "Tarefa não encontrada." });
    }

    res.json(tarefa);
  } catch (erro) {
    next(erro);
  }
}

// POST /tarefas
async function criar(req, res, next) {
  try {
    const { titulo, descricao, status, prioridade, prazo, tags } = req.body;

    const tarefa = await Tarefa.create({
      titulo,
      descricao,
      status,
      prioridade,
      prazo: prazo ? new Date(prazo) : null,
      tags,
      usuario: req.usuario._id,
    });

    res.status(201).json(tarefa);
  } catch (erro) {
    next(erro);
  }
}

// PUT /tarefas/:id
async function atualizar(req, res, next) {
  try {
    if (!mongoose.isValidObjectId(req.params.id)) {
      return res.status(400).json({ erro: "ID inválido." });
    }

    const { titulo, descricao, status, prioridade, prazo, tags } = req.body;

    const tarefa = await Tarefa.findOneAndUpdate(
      { _id: req.params.id, usuario: req.usuario._id },
      {
        $set: {
          ...(titulo !== undefined && { titulo }),
          ...(descricao !== undefined && { descricao }),
          ...(status !== undefined && { status }),
          ...(prioridade !== undefined && { prioridade }),
          ...(tags !== undefined && { tags }),
          // prazo segue a mesma regra dos outros campos: ausente no corpo,
          // fica como está. Escrito solto, como antes, um PUT que só muda o
          // título apagava o prazo da tarefa — sem erro nenhum.
          ...(prazo !== undefined && { prazo: prazo ? new Date(prazo) : null }),
        },
      },
      { new: true, runValidators: true }
    );

    if (!tarefa) {
      return res.status(404).json({ erro: "Tarefa não encontrada." });
    }

    res.json(tarefa);
  } catch (erro) {
    next(erro);
  }
}

// DELETE /tarefas/:id
async function remover(req, res, next) {
  try {
    if (!mongoose.isValidObjectId(req.params.id)) {
      return res.status(400).json({ erro: "ID inválido." });
    }

    const tarefa = await Tarefa.findOneAndDelete({
      _id: req.params.id,
      usuario: req.usuario._id,
    });

    if (!tarefa) {
      return res.status(404).json({ erro: "Tarefa não encontrada." });
    }

    res.json({ mensagem: `Tarefa "${tarefa.titulo}" removida com sucesso.` });
  } catch (erro) {
    next(erro);
  }
}

module.exports = { listar, estatisticas, buscarUma, criar, atualizar, remover };

Rotas

// src/routes/auth.js
const express = require("express");
const router = express.Router();
const { registrar, login, eu, alterarSenha } = require("../controllers/authController");
const { autenticar } = require("../middlewares/auth");

router.post("/registrar", registrar);
router.post("/login", login);
router.get("/eu", autenticar, eu);
router.patch("/senha", autenticar, alterarSenha);

module.exports = router;
// src/routes/tarefas.js
const express = require("express");
const router = express.Router();
const ctrl = require("../controllers/tarefaController");
const { autenticar } = require("../middlewares/auth");

// Todas as rotas de tarefas exigem autenticação
router.use(autenticar);

router.get("/", ctrl.listar);
router.get("/estatisticas", ctrl.estatisticas);
router.get("/:id", ctrl.buscarUma);
router.post("/", ctrl.criar);
router.put("/:id", ctrl.atualizar);
router.delete("/:id", ctrl.remover);

module.exports = router;

Middlewares de suporte

// src/middlewares/logger.js
function logger(req, res, next) {
  const inicio = Date.now();
  res.on("finish", () => {
    const ms = Date.now() - inicio;
    const ok = res.statusCode < 400;
    const cor = ok ? "\x1b[32m" : "\x1b[31m";
    const reset = "\x1b[0m";
    console.log(
      `${cor}[${new Date().toLocaleTimeString("pt-BR")}]${reset} ` +
      `${req.method.padEnd(7)} ${req.path.padEnd(30)} ` +
      `${cor}${res.statusCode}${reset} ${ms}ms`
    );
  });
  next();
}

module.exports = logger;
// src/middlewares/erros.js
function naoEncontrado(req, res) {
  res.status(404).json({
    erro: `Rota ${req.method} ${req.path} não encontrada.`,
  });
}

function tratadorDeErros(erro, req, res, next) {
  console.error(`[Erro] ${erro.name}: ${erro.message}`);

  if (erro.name === "CastError") {
    return res.status(400).json({ erro: `ID inválido: ${erro.value}` });
  }

  if (erro.name === "ValidationError") {
    const detalhes = Object.values(erro.errors).map(e => e.message);
    return res.status(422).json({ erro: "Dados inválidos.", detalhes });
  }

  if (erro.code === 11000) {
    const campo = Object.keys(erro.keyValue)[0];
    return res.status(409).json({ erro: `${campo} já está em uso.` });
  }

  if (erro.type === "entity.parse.failed") {
    return res.status(400).json({ erro: "JSON inválido no corpo da requisição." });
  }

  const status = erro.status || 500;
  res.status(status).json({
    erro: status === 500 ? "Erro interno do servidor." : erro.message,
  });
}

module.exports = { naoEncontrado, tratadorDeErros };

O ponto de entrada

// src/config/database.js
const mongoose = require("mongoose");

async function conectar() {
  await mongoose.connect(process.env.MONGODB_URL);
  console.log(`✅ MongoDB: ${mongoose.connection.host}`);

  mongoose.connection.on("error", err =>
    console.error("❌ MongoDB erro:", err.message)
  );
}

module.exports = { conectar };
// src/index.js
require("dotenv").config();
const express = require("express");
const { conectar } = require("./config/database");
const logger = require("./middlewares/logger");
const { naoEncontrado, tratadorDeErros } = require("./middlewares/erros");
const authRoutes = require("./routes/auth");
const tarefasRoutes = require("./routes/tarefas");

const app = express();

// ── Middlewares globais ────────────────────────────
app.use(logger);
app.use(express.json());

// ── CORS ──────────────────────────────────────────
app.use((req, res, next) => {
  res.set({
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
  });
  if (req.method === "OPTIONS") return res.sendStatus(204);
  next();
});

// ── Rota de health check ──────────────────────────
app.get("/health", (req, res) => {
  res.json({
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: Math.floor(process.uptime()) + "s",
  });
});

// ── Rotas da API ──────────────────────────────────
app.use("/auth", authRoutes);
app.use("/tarefas", tarefasRoutes);

// ── Erros ─────────────────────────────────────────
app.use(naoEncontrado);
app.use(tratadorDeErros);

// ── Inicialização ─────────────────────────────────
const PORTA = process.env.PORT || 3000;

conectar()
  .then(() => {
    app.listen(PORTA, () => {
      console.log(`\n🚀 API rodando em http://localhost:${PORTA}`);
      console.log(`📋 Ambiente: ${process.env.NODE_ENV || "development"}\n`);
    });
  })
  .catch(err => {
    console.error("Falha na inicialização:", err.message);
    process.exit(1);
  });

// Encerramento gracioso
process.on("SIGTERM", () => {
  console.log("Encerrando servidor...");
  process.exit(0);
});

module.exports = app;

Testando a API completa

# 1. Registrar usuário
curl -X POST http://localhost:3000/auth/registrar \
  -H "Content-Type: application/json" \
  -d '{"nome": "Ana Paula", "email": "ana@email.com", "senha": "senha123"}'

# 2. Login — guarde o token retornado
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "ana@email.com", "senha": "senha123"}'

# 3. Criar tarefa (use o token do login)
curl -X POST http://localhost:3000/tarefas \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer SEU_TOKEN_AQUI" \
  -d '{"titulo": "Estudar Node.js", "prioridade": "alta", "prazo": "2025-12-31"}'

# 4. Listar tarefas
curl http://localhost:3000/tarefas \
  -H "Authorization: Bearer SEU_TOKEN_AQUI"

# 5. Filtrar por status
curl "http://localhost:3000/tarefas?status=pendente&pagina=1&por_pagina=5" \
  -H "Authorization: Bearer SEU_TOKEN_AQUI"

# 6. Estatísticas
curl http://localhost:3000/tarefas/estatisticas \
  -H "Authorization: Bearer SEU_TOKEN_AQUI"

# 7. Sem token — deve retornar 401
curl http://localhost:3000/tarefas

O que este projeto exercitou

Conceito do Módulo 4 Onde foi aplicado
Node.js e process dotenv, process.exit, SIGTERM
NPM e package.json scripts, dependencies, devDependencies
Servidor HTTP / Express app, router, middlewares
Router modular routes/auth.js, routes/tarefas.js
Middlewares customizados logger, autenticar, erros
MongoDB e Mongoose Models, CRUD, aggregate
Schema com validações Usuario.js, Tarefa.js
Hooks do Mongoose pre-save para hash de senha
Virtuals tarefa.atrasada
Autenticação JWT registro, login, token, rotas protegidas
Hash de senhas bcrypt com salt 12
Tratamento de erros ValidationError, CastError, 11000
Paginação skip, limit, countDocuments

O projeto reúne as peças na ordem em que elas costumam aparecer num serviço real: rota, validação de entrada, persistência, autenticação e tratamento de erro. O que ele deixa evidente é que nenhuma dessas camadas é opcional — uma API sem validação funciona até o primeiro corpo malformado, e uma sem tratamento de erro funciona até a primeira consulta que não encontra nada.

Fontes e Referências

Exercícios

Exercício 1

Esta era a atualização de tarefa antes da correção. O usuário edita só o título de uma tarefa que tinha prazo para sexta. O que acontece com o prazo?

$set: {
  ...(titulo !== undefined && { titulo }),
  ...(descricao !== undefined && { descricao }),
  ...(status !== undefined && { status }),
  ...(tags !== undefined && { tags }),
  prazo: prazo ? new Date(prazo) : null,
}
Ver resposta

✓ Resposta: O prazo é apagado. Todos os outros campos entram no $set condicionalmente — o spread de false não adiciona nada, então um campo ausente no corpo simplesmente não é tocado. O prazo ficou fora desse padrão: ele é escrito sempre, e como não veio no corpo, prazo é undefined, o ternário cai no null e a data vai para o banco zerada. É perda silenciosa de dado: nada falha, o status é 200, a resposta traz a tarefa atualizada, e a única pista é o prazo que sumiu da tela. A correção é trazê-lo para a mesma regra: ...(prazo !== undefined && { prazo: prazo ? new Date(prazo) : null }) — assim o campo só é escrito quando o cliente realmente o enviou, e mandar "prazo": null continua sendo a forma de limpá-lo de propósito. Vale reparar que o defeito nasce da quebra de padrão: quatro linhas seguem uma regra, a quinta não, e o olho passa direto porque o bloco inteiro parece uniforme. É também por isso que o PATCH costuma ser preferido ao PUT para edição parcial — o PUT, pela definição do protocolo, substitui o recurso inteiro, e um cliente que siga a especificação à risca esperaria mesmo que os campos omitidos fossem apagados.

Exercício 2

A tarefa tem prazo vencido. Ela aparece com atrasada: true ao ser buscada por id, mas o campo não existe na listagem. Por quê?

// no model
tarefaSchema.virtual("atrasada").get(function () {
  if (!this.prazo || this.status === "concluida") return false;
  return new Date() > this.prazo;
});
tarefaSchema.set("toJSON", { virtuals: true });

// no listar
const tarefas = await Tarefa.find(filtros).sort(ordenar).limit(10).lean();

// no buscarUma
const tarefa = await Tarefa.findOne({ _id: req.params.id, usuario: req.usuario._id });
Ver resposta

✓ Resposta: Porque o .lean() da listagem devolve objetos JavaScript puros, direto do driver do MongoDB, sem passar pela camada de documento do Mongoose — e virtual é justamente coisa dessa camada. Sem documento, não há getter; sem getter, não há atrasada. O buscarUma não usa lean(), devolve um documento de verdade, e aí o toJSON com virtuals: true inclui o campo. O resultado é uma API que contradiz a si mesma: o mesmo recurso tem formatos diferentes conforme o endpoint, e o front-end que exibe o alerta de atraso na tela de detalhe não consegue exibi-lo na lista — sem nenhum erro para investigar, porque o campo simplesmente não vem. Há três saídas, e a escolha depende do que importa mais. Tirar o lean() resolve e custa desempenho, que era a razão de ele existir. Calcular o atraso no cliente funciona e duplica a regra em dois lugares. A terceira, e melhor para listagem, é deixar o próprio banco responder: um $addFields num aggregate, ou simplesmente devolver o prazo e deixar claro no contrato da API que "atrasada" é derivado. O princípio geral: todo endpoint que devolve o mesmo recurso deve devolver o mesmo formato, e otimização que muda o contrato não é otimização, é bug.

Exercício 3

Um atacante quer descobrir quais emails têm conta no sistema. Ele não tem nenhuma senha. Como este login entrega a informação?

const usuario = await Usuario.findOne({ email }).select("+senha");
if (!usuario) {
  return res.status(401).json({ erro: "Credenciais inválidas." });
}

const senhaCorreta = await usuario.verificarSenha(senha); // bcrypt, fator 12
if (!senhaCorreta) {
  return res.status(401).json({ erro: "Credenciais inválidas." });
}
Ver resposta

✓ Resposta: Pelo tempo de resposta. A mensagem está correta — as duas falhas dizem "Credenciais inválidas", sem revelar qual delas falhou —, mas o caminho percorrido é diferente. Quando o email não existe, a função retorna logo após a consulta, em poucos milissegundos. Quando existe, ela ainda executa o bcrypt.compare, que é propositalmente lento: com fator 12 são algo como 200 a 300 ms de processamento. Medindo a diferença, o atacante separa os emails cadastrados dos não cadastrados com boa confiança, e o ataque é barato de automatizar. O nome disso é timing attack, e a defesa é fazer o mesmo trabalho nos dois caminhos: guardar um hash descartável e compará-lo quando o usuário não existe, de modo que o tempo seja equivalente. Repare que o projeto inteiro tem outra brecha do mesmo tipo, mais fácil de explorar: o registrar responde 409 "Email já cadastrado", o que entrega a mesma informação sem esforço nenhum. A saída aqui não é técnica, é de produto — sistemas que levam isso a sério respondem sempre "enviamos um email de confirmação" e contam a verdade apenas por email, para quem realmente é dono da caixa. E vale a ressalva de sempre: vazar quem tem conta pode parecer inofensivo até o dia em que o serviço é de um assunto sensível.

Exercício 4

A busca por título repassa direto ao MongoDB o que veio na query string. O que acontece quando alguém pesquisa por (a+)+$?

const { busca } = req.query;

if (busca) filtros.titulo = { $regex: busca, $options: "i" };
Ver resposta

✓ Resposta: O servidor trava numa única requisição. (a+)+$ é uma expressão com retrocesso catastrófico: diante de um texto que quase casa, o motor de regex tenta uma quantidade exponencial de combinações, e uma string de algumas dezenas de caracteres já leva minutos de processamento. Como a avaliação acontece no MongoDB, quem congela é o banco — e ele é compartilhado por todos os usuários. O ataque tem nome, ReDoS, e uma requisição basta. Há um segundo problema, mais corriqueiro e igualmente real: sem escape, buscar por C++ ou por (rascunho) monta uma expressão inválida e a busca estoura com erro de sintaxe — o mesmo defeito que apareceu no artigo de eventos, na busca ao vivo. A correção é escapar os metacaracteres antes de montar o padrão, com busca.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), e limitar o tamanho do termo. Melhor ainda, para busca de texto de verdade, é usar um índice de texto com $text: ele não sofre desse problema, entende variações da palavra e ainda devolve uma pontuação de relevância. E um detalhe de desempenho que costuma passar batido: $regex sem âncora no início não aproveita índice nenhum, então mesmo o caso benigno percorre a coleção inteira.

Exercício 5

Um usuário copia o token que recebeu no login e cola em jwt.io. O que ele consegue ler? E por que o middleware vai ao banco em toda requisição, se o token já traz o id?

const token = jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "7d" });

// no middleware
const payload = jwt.verify(token, process.env.JWT_SECRET);
const usuario = await Usuario.findById(payload.id);
if (!usuario || !usuario.ativo) {
  return res.status(401).json({ erro: "Usuário não encontrado ou inativo." });
}
Ver resposta

✓ Resposta: Ele lê tudo o que está no payload. Um JWT tem três partes separadas por ponto, e as duas primeiras — cabeçalho e payload — são apenas Base64, que é codificação, não criptografia: qualquer pessoa decodifica sem chave nenhuma. A assinatura, a terceira parte, garante que o conteúdo não foi alterado, não que ele seja secreto. Daí a regra: nunca coloque no token o que não pode ser lido — senha, CPF, papel interno, endereço. Aqui só vai o id, o que está correto. Quanto à segunda pergunta: o token é autocontido e não pode ser cancelado. Uma vez emitido, ele vale até expirar, e sete dias é bastante tempo; se o usuário for desativado, banido ou tiver a senha trocada depois de um vazamento, o token antigo continuaria funcionando. A consulta ao banco é o que permite revogar de fato — e é por isso que ela testa usuario.ativo, não apenas a existência. O custo é abrir mão de parte da vantagem do JWT, que era justamente não consultar o banco; projetos maiores equilibram isso com um access token de vida curta, de minutos, mais um refresh token guardado no servidor, que pode ser revogado. Vale fechar com o ponto que não aparece no código: o JWT_SECRET é o que sustenta tudo. Vazado, ele permite a qualquer um assinar um token com o id que quiser — e o .env de exemplo do artigo, com sua-chave-secreta-muito-longa-e-aleatoria-aqui, é um lembrete de que essa chave precisa ser de verdade aleatória e nunca ser versionada.

Comentários

Mais em Javascript

Arquitetura de Software: SOLID, Clean Architecture e DDD
Arquitetura de Software: SOLID, Clean Architecture e DDD

Se houver uma frase para levar daqui, é a regra das dependências: elas apontam…

Revisão + Projeto Final: SPA Completa
Revisão + Projeto Final: SPA Completa

As cinco peças do módulo em uma aplicação só: rotas com layout e proteção…

Escopo, Hoisting e Closures
Escopo, Hoisting e Closures

Um laço com var e um setTimeout dentro imprime 4, 4, 4 — e explicar esse…