leonardo пре 2 месеци
родитељ
комит
76092db45f

+ 19 - 0
db/migrations/20260706120000_create_solicitacoes_reset_senha_table.cjs

@@ -0,0 +1,19 @@
+exports.up = function (knex) {
+  return knex.schema.createTable("solicitacoes_reset_senha", (t) => {
+    t.increments("Id");
+    t.integer("UsuarioId").unsigned().notNullable();
+    t.string("Status", 20).notNullable().defaultTo("pendente"); // pendente | atendida | dispensada
+    t.integer("AtendidaPorId").unsigned().nullable();
+    t.dateTime("AtendidaEm").nullable();
+    t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+
+    t.foreign("UsuarioId").references("usuarios.Id").onDelete("CASCADE");
+    t.foreign("AtendidaPorId").references("usuarios.Id").onDelete("SET NULL");
+    t.index(["UsuarioId", "Status"]);
+    t.index(["Status"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.dropTable("solicitacoes_reset_senha");
+};

+ 63 - 0
src/controllers/Auth.Controller.js

@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
 import { AppError } from "../shared/errors/index.js";
 import { Usuario } from "../models/Usuario.model.js";
 import { RefreshToken } from "../models/RefreshToken.model.js";
+import { SolicitacaoResetSenha } from "../models/SolicitacaoResetSenha.model.js";
 import { signAccessToken, generateRefreshToken, hashRefreshToken } from "../services/authTokens.js";
 import { config } from "../config/index.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
@@ -12,6 +13,10 @@ const BLOCK_MS = 15 * 60 * 1000;
 const MAX_TRACKED_KEYS = 10_000;
 const REUSE_GRACE_MS = 60 * 1000;
 
+const _resetAttempts = new Map();
+const RESET_MAX = 5;
+const RESET_WINDOW_MS = 15 * 60 * 1000;
+
 
 const DUMMY_HASH = bcrypt.hashSync("oraculo-dummy-password", 10);
 
@@ -21,6 +26,9 @@ setInterval(() => {
     const expired = entry.blockedUntil > 0 ? entry.blockedUntil <= now : entry.lastAttempt + BLOCK_MS <= now;
     if (expired) _failedAttempts.delete(key);
   }
+  for (const [ip, entry] of _resetAttempts) {
+    if (entry.windowStart + RESET_WINDOW_MS <= now) _resetAttempts.delete(ip);
+  }
 }, 60_000).unref();
 
 
@@ -45,6 +53,23 @@ function recordFailedAttempt(key) {
   _failedAttempts.set(key, entry);
 }
 
+function resetRetryAfterSeconds(ip) {
+  const now = Date.now();
+  const entry = _resetAttempts.get(ip);
+  if (!entry || now - entry.windowStart > RESET_WINDOW_MS) {
+    if (_resetAttempts.size >= MAX_TRACKED_KEYS) {
+      _resetAttempts.delete(_resetAttempts.keys().next().value);
+    }
+    _resetAttempts.set(ip, { count: 1, windowStart: now });
+    return 0;
+  }
+  entry.count += 1;
+  if (entry.count > RESET_MAX) {
+    return Math.ceil((entry.windowStart + RESET_WINDOW_MS - now) / 1000);
+  }
+  return 0;
+}
+
 function blockedSecondsRemaining(key) {
   const entry = _failedAttempts.get(key);
   if (!entry) return 0;
@@ -159,6 +184,44 @@ export const AuthController = {
     }
   },
 
+  SolicitarResetSenha: async function (req, res, next) {
+    try {
+      const loginInformado = req.body.login.trim();
+
+      const retryAfterSeconds = resetRetryAfterSeconds(req.ip ?? "unknown");
+      if (retryAfterSeconds > 0) {
+        const err = new AppError("Muitas solicitações. Tente novamente mais tarde.", 429);
+        err.details = { retryAfterSeconds };
+        throw err;
+      }
+
+      // Email não tem constraint de unicidade — pega o primeiro match, como no Login
+      const usuario = await Usuario.query()
+        .where(loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado })
+        .orderBy("Id")
+        .first();
+
+      // Anti-enumeração: usuário inexistente, inativo ou com solicitação pendente
+      // recebem exatamente a mesma resposta de sucesso
+      if (usuario && String(usuario.Status) !== "0") {
+        const pendente = await SolicitacaoResetSenha.query().findOne({
+          UsuarioId: usuario.Id,
+          Status: "pendente"
+        });
+        if (!pendente) {
+          await SolicitacaoResetSenha.query().insert({ UsuarioId: usuario.Id, Status: "pendente" });
+        }
+      }
+
+      return res.status(200).send({
+        status: true,
+        msg: "Se o usuário existir, a solicitação foi registrada. Um administrador fará contato."
+      });
+    } catch (error) {
+      return next(error);
+    }
+  },
+
   Logout: async function (req, res, next) {
     try {
       const { refreshToken } = req.body ?? {};

+ 43 - 0
src/controllers/Usuario.Controller.js

@@ -1,6 +1,7 @@
 import bcrypt from "bcryptjs";
 import { AppError, NotFoundError, BadRequestError } from "../shared/errors/index.js";
 import { Usuario } from "../models/Usuario.model.js";
+import { SolicitacaoResetSenha } from "../models/SolicitacaoResetSenha.model.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
 
 export const UsuarioController = {
@@ -110,10 +111,52 @@ export const UsuarioController = {
       const novaHash = bcrypt.hashSync(senhaNova, 10);
       await usuario.$query().patch({ Senha: novaHash });
 
+      if (callerIsAdmin) {
+        await SolicitacaoResetSenha.query()
+          .patch({ Status: "atendida", AtendidaPorId: Number(req.user.sub), AtendidaEm: new Date() })
+          .where({ UsuarioId: id, Status: "pendente" });
+      }
+
       return res.status(200).send({ status: true, msg: "Senha alterada com sucesso!" });
     } catch (error) {
       return next(error);
     }
+  },
+
+  ListarSolicitacoesReset: async function (req, res, next) {
+    try {
+      const solicitacoes = await SolicitacaoResetSenha.query()
+        .where({ Status: "pendente" })
+        .withGraphFetched("usuario")
+        .modifyGraph("usuario", (builder) => builder.select("Id", "Nome", "Login", "Email", "Setor"))
+        .orderBy("CreatedAt", "asc");
+
+      return res.status(200).send({ status: true, items: solicitacoes });
+    } catch (error) {
+      return next(error);
+    }
+  },
+
+  DispensarSolicitacaoReset: async function (req, res, next) {
+    try {
+      const id = Number(req.params.id);
+      if (!id) throw new BadRequestError("ID inválido");
+
+      const solicitacao = await SolicitacaoResetSenha.query().findById(id);
+      if (!solicitacao || solicitacao.Status !== "pendente") {
+        throw new NotFoundError("Solicitação não encontrada ou já resolvida");
+      }
+
+      await solicitacao.$query().patch({
+        Status: "dispensada",
+        AtendidaPorId: Number(req.user.sub),
+        AtendidaEm: new Date()
+      });
+
+      return res.status(200).send({ status: true, msg: "Solicitação dispensada." });
+    } catch (error) {
+      return next(error);
+    }
   }
 };
 

+ 1 - 1
src/middleware/Validajwt.js

@@ -14,7 +14,7 @@ export function authMiddleware(req, res, next) {
   const header = req.header("authorization") ?? "";
   const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
 
-  const isPublic = req.path === "/auth/login" || req.path === "/auth/logout" || req.path === "/auth/refresh";
+  const isPublic = req.path === "/auth/login" || req.path === "/auth/logout" || req.path === "/auth/refresh" || req.path === "/auth/esqueci-senha";
 
   const hasAuthConfigured = Boolean(config.apiKey || config.jwt.secret);
   if (!hasAuthConfigured) return next();

+ 4 - 0
src/middleware/schemas/Auth.Schema.js

@@ -9,3 +9,7 @@ export const loginSchema = z.object({
 export const refreshSchema = z.object({
   refreshToken: z.string().min(1)
 });
+
+export const esqueciSenhaSchema = z.object({
+  login: z.string().min(1).max(150)
+});

+ 25 - 0
src/models/SolicitacaoResetSenha.model.js

@@ -0,0 +1,25 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+import { Usuario } from "./Usuario.model.js";
+
+export class SolicitacaoResetSenha extends Model {
+  static get tableName() {
+    return "solicitacoes_reset_senha";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  static get relationMappings() {
+    return {
+      usuario: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: Usuario,
+        join: { from: "solicitacoes_reset_senha.UsuarioId", to: "usuarios.Id" }
+      }
+    };
+  }
+}
+
+export default SolicitacaoResetSenha;

+ 2 - 1
src/routes/Auth.Rotas.js

@@ -1,10 +1,11 @@
 import { Router } from "express";
 import { AuthController } from "../controllers/Auth.Controller.js";
 import { validate } from "../middleware/Validate.js";
-import { loginSchema, refreshSchema } from "../middleware/schemas/Auth.Schema.js";
+import { loginSchema, refreshSchema, esqueciSenhaSchema } from "../middleware/schemas/Auth.Schema.js";
 
 export const authRouter = Router();
 
 authRouter.post("/login", validate({ body: loginSchema }), AuthController.Login);
 authRouter.post("/logout", AuthController.Logout);
 authRouter.post("/refresh", validate({ body: refreshSchema }), AuthController.Refresh);
+authRouter.post("/esqueci-senha", validate({ body: esqueciSenhaSchema }), AuthController.SolicitarResetSenha);

+ 7 - 5
src/routes/Usuario.Rotas.js

@@ -7,8 +7,10 @@ import { criarSchema, atualizarSchema, senhaSchema } from "../middleware/schemas
 
 export const usersRouter = Router();
 
-usersRouter.get("/",             requireUser, requireAdmin,                                  UsuarioController.Listar);
-usersRouter.post("/",            requireUser, requireAdmin, validate({ body: criarSchema }),     UsuarioController.Criar);
-usersRouter.put("/:id",          requireUser, requireAdmin, validate({ body: atualizarSchema }), UsuarioController.Atualizar);
-usersRouter.patch("/:id/status", requireUser, requireAdmin,                                  UsuarioController.ToggleStatus);
-usersRouter.patch("/:id/senha",  requireUser,               validate({ body: senhaSchema }),     UsuarioController.AlterarSenha);
+usersRouter.get("/solicitacoes-reset",requireUser, requireAdmin, UsuarioController.ListarSolicitacoesReset);
+usersRouter.patch("/solicitacoes-reset/:id/dispensar", requireUser, requireAdmin,UsuarioController.DispensarSolicitacaoReset);
+usersRouter.get("/", requireUser, requireAdmin, UsuarioController.Listar);
+usersRouter.post("/", requireUser, requireAdmin, validate({ body: criarSchema }), UsuarioController.Criar);
+usersRouter.put("/:id", requireUser, requireAdmin, validate({ body: atualizarSchema }), UsuarioController.Atualizar);
+usersRouter.patch("/:id/status", requireUser, requireAdmin, UsuarioController.ToggleStatus);
+usersRouter.patch("/:id/senha", requireUser, validate({ body: senhaSchema }), UsuarioController.AlterarSenha);