|
|
@@ -9,24 +9,49 @@ import { formatarUsuario } from "../utils/formatarUsuario.js";
|
|
|
const _failedAttempts = new Map();
|
|
|
const MAX_ATTEMPTS = 5;
|
|
|
const BLOCK_MS = 15 * 60 * 1000;
|
|
|
+const MAX_TRACKED_KEYS = 10_000;
|
|
|
+const REUSE_GRACE_MS = 60 * 1000;
|
|
|
|
|
|
-function recordFailedAttempt(login) {
|
|
|
+
|
|
|
+const DUMMY_HASH = bcrypt.hashSync("oraculo-dummy-password", 10);
|
|
|
+
|
|
|
+setInterval(() => {
|
|
|
+ const now = Date.now();
|
|
|
+ for (const [key, entry] of _failedAttempts) {
|
|
|
+ const expired = entry.blockedUntil > 0 ? entry.blockedUntil <= now : entry.lastAttempt + BLOCK_MS <= now;
|
|
|
+ if (expired) _failedAttempts.delete(key);
|
|
|
+ }
|
|
|
+}, 60_000).unref();
|
|
|
+
|
|
|
+
|
|
|
+function attemptKey(req, login) {
|
|
|
+ return `${req.ip ?? "unknown"}|${login}`;
|
|
|
+}
|
|
|
+
|
|
|
+function recordFailedAttempt(key) {
|
|
|
const now = Date.now();
|
|
|
- const entry = _failedAttempts.get(login) ?? { count: 0, blockedUntil: 0 };
|
|
|
+ const entry = _failedAttempts.get(key) ?? { count: 0, blockedUntil: 0, lastAttempt: 0 };
|
|
|
entry.count += 1;
|
|
|
+ entry.lastAttempt = now;
|
|
|
if (entry.count >= MAX_ATTEMPTS) {
|
|
|
entry.blockedUntil = now + BLOCK_MS;
|
|
|
entry.count = 0;
|
|
|
}
|
|
|
- _failedAttempts.set(login, entry);
|
|
|
+
|
|
|
+ _failedAttempts.delete(key);
|
|
|
+ if (_failedAttempts.size >= MAX_TRACKED_KEYS) {
|
|
|
+ _failedAttempts.delete(_failedAttempts.keys().next().value);
|
|
|
+ }
|
|
|
+ _failedAttempts.set(key, entry);
|
|
|
}
|
|
|
|
|
|
-function isLoginBlocked(login) {
|
|
|
- const entry = _failedAttempts.get(login);
|
|
|
- if (!entry) return false;
|
|
|
- if (entry.blockedUntil > Date.now()) return true;
|
|
|
- if (entry.blockedUntil > 0) _failedAttempts.delete(login);
|
|
|
- return false;
|
|
|
+function blockedSecondsRemaining(key) {
|
|
|
+ const entry = _failedAttempts.get(key);
|
|
|
+ if (!entry) return 0;
|
|
|
+ const remainingMs = entry.blockedUntil - Date.now();
|
|
|
+ if (remainingMs > 0) return Math.ceil(remainingMs / 1000);
|
|
|
+ if (entry.blockedUntil > 0) _failedAttempts.delete(key);
|
|
|
+ return 0;
|
|
|
}
|
|
|
|
|
|
export const AuthController = {
|
|
|
@@ -36,31 +61,30 @@ export const AuthController = {
|
|
|
|
|
|
const loginInformado = login.trim();
|
|
|
const senhaInformada = senha;
|
|
|
+ const chaveTentativa = attemptKey(req, loginInformado);
|
|
|
|
|
|
- if (isLoginBlocked(loginInformado)) {
|
|
|
- throw new AppError("Muitas tentativas incorretas. Tente novamente em 15 minutos.", 429);
|
|
|
+ const retryAfterSeconds = blockedSecondsRemaining(chaveTentativa);
|
|
|
+ if (retryAfterSeconds > 0) {
|
|
|
+ const err = new AppError("Muitas tentativas incorretas. Tente novamente mais tarde.", 429);
|
|
|
+ err.details = { retryAfterSeconds };
|
|
|
+ throw err;
|
|
|
}
|
|
|
|
|
|
const usuario = await Usuario.query().findOne(
|
|
|
loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado }
|
|
|
);
|
|
|
|
|
|
- if (!usuario) {
|
|
|
- recordFailedAttempt(loginInformado);
|
|
|
- throw new AppError("Combinação de usuário e senha inválida!", 401);
|
|
|
- }
|
|
|
+
|
|
|
+ const hashSenha = usuario ? String(usuario.Senha ?? "") : DUMMY_HASH;
|
|
|
+ const passwordIsValid = await bcrypt.compare(senhaInformada, hashSenha);
|
|
|
+ const contaInativa = usuario ? String(usuario.Status) === "0" : false;
|
|
|
|
|
|
- if (String(usuario.Status) === "0") {
|
|
|
- throw new AppError("Usuario inativo!", 401);
|
|
|
- }
|
|
|
-
|
|
|
- const passwordIsValid = bcrypt.compareSync(senhaInformada, String(usuario.Senha ?? ""));
|
|
|
- if (!passwordIsValid) {
|
|
|
- recordFailedAttempt(loginInformado);
|
|
|
+ if (!usuario || !passwordIsValid || contaInativa) {
|
|
|
+ recordFailedAttempt(chaveTentativa);
|
|
|
throw new AppError("Combinação de usuário e senha inválida!", 401);
|
|
|
}
|
|
|
|
|
|
- _failedAttempts.delete(loginInformado);
|
|
|
+ _failedAttempts.delete(chaveTentativa);
|
|
|
|
|
|
let accessToken = null;
|
|
|
let refreshToken = null;
|
|
|
@@ -92,12 +116,21 @@ export const AuthController = {
|
|
|
try {
|
|
|
const { refreshToken } = req.body;
|
|
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
|
- const stored = await RefreshToken.query()
|
|
|
- .findOne({ TokenHash: tokenHash })
|
|
|
- .whereNull("RevokedAt")
|
|
|
- .where("ExpiresAt", ">", new Date());
|
|
|
+ const stored = await RefreshToken.query().findOne({ TokenHash: tokenHash });
|
|
|
|
|
|
- if (!stored) {
|
|
|
+ if (!stored || new Date(stored.ExpiresAt) <= new Date()) {
|
|
|
+ throw new AppError("Refresh token inválido ou expirado!", 401);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (stored.RevokedAt) {
|
|
|
+
|
|
|
+ const revokedThereMs = Date.now() - new Date(stored.RevokedAt).getTime();
|
|
|
+ if (revokedThereMs > REUSE_GRACE_MS) {
|
|
|
+ await RefreshToken.query()
|
|
|
+ .patch({ RevokedAt: new Date() })
|
|
|
+ .where({ UsuarioId: stored.UsuarioId })
|
|
|
+ .whereNull("RevokedAt");
|
|
|
+ }
|
|
|
throw new AppError("Refresh token inválido ou expirado!", 401);
|
|
|
}
|
|
|
|
|
|
@@ -108,7 +141,19 @@ export const AuthController = {
|
|
|
|
|
|
const accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
|
|
|
|
|
|
- return res.status(200).send({ status: true, accessToken });
|
|
|
+
|
|
|
+ const novoRefreshToken = generateRefreshToken();
|
|
|
+ await RefreshToken.query()
|
|
|
+ .patch({ RevokedAt: new Date() })
|
|
|
+ .where({ Id: stored.Id })
|
|
|
+ .whereNull("RevokedAt");
|
|
|
+ await RefreshToken.query().insert({
|
|
|
+ UsuarioId: usuario.Id,
|
|
|
+ TokenHash: hashRefreshToken(novoRefreshToken),
|
|
|
+ ExpiresAt: new Date(Date.now() + config.jwt.refreshTtlSeconds * 1000)
|
|
|
+ });
|
|
|
+
|
|
|
+ return res.status(200).send({ status: true, accessToken, refreshToken: novoRefreshToken });
|
|
|
} catch (error) {
|
|
|
return next(error);
|
|
|
}
|