|
|
@@ -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 ?? {};
|