Skip to content

Instantly share code, notes, and snippets.

@animir
Last active November 10, 2023 16:57
Show Gist options
  • Save animir/5a2b37eeb72f78365fde512506cfa93a to your computer and use it in GitHub Desktop.
Save animir/5a2b37eeb72f78365fde512506cfa93a to your computer and use it in GitHub Desktop.
Node.js rate-limiter-flexible. Brute-force protection - Block source of requests by IP plus Username and IP.
const http = require('http');
const express = require('express');
const Redis = require('ioredis');
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = new Redis({ enableOfflineQueue: false });
const maxWrongAttemptsByIPperDay = 100;
const maxConsecutiveFailsByUsernameAndIP = 10;
const limiterSlowBruteByIP = new RateLimiterRedis({
redis: redisClient,
keyPrefix: 'login_fail_ip_per_day',
points: maxWrongAttemptsByIPperDay,
duration: 60 * 60 * 24,
blockDuration: 60 * 60 * 24, // Block for 1 day, if 100 wrong attempts per day
});
const limiterConsecutiveFailsByUsernameAndIP = new RateLimiterRedis({
redis: redisClient,
keyPrefix: 'login_fail_consecutive_username_and_ip',
points: maxConsecutiveFailsByUsernameAndIP,
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
blockDuration: 60 * 60 * 24 * 365 * 20, // Block for infinity after consecutive fails
});
const getUsernameIPkey = (username, ip) => `${username}_${ip}`;
async function loginRoute(req, res) {
const ipAddr = req.connection.remoteAddress;
const usernameIPkey = getUsernameIPkey(req.body.email, ipAddr);
const [resUsernameAndIP, resSlowByIP] = await Promise.all([
limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey),
limiterSlowBruteByIP.get(ipAddr),
]);
let retrySecs = 0;
// Check if IP or Username + IP is already blocked
if (resSlowByIP !== null && resSlowByIP.consumedPoints > maxWrongAttemptsByIPperDay) {
retrySecs = Math.round(resSlowByIP.msBeforeNext / 1000) || 1;
} else if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > maxConsecutiveFailsByUsernameAndIP) {
retrySecs = Math.round(resUsernameAndIP.msBeforeNext / 1000) || 1;
}
if (retrySecs > 0) {
res.set('Retry-After', String(retrySecs));
res.status(429).send('Too Many Requests');
} else {
const user = authorise(req.body.email, req.body.password);
if (!user.isLoggedIn) {
// Consume 1 point from limiters on wrong attempt and block if limits reached
try {
const promises = [limiterSlowBruteByIP.consume(ipAddr)];
if (user.exists) {
// Count failed attempts by Username + IP only for registered users
promises.push(limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey));
}
await Promise.all(promises);
res.status(400).end('email or password is wrong');
} catch (rlRejected) {
if (rlRejected instanceof Error) {
throw rlRejected;
} else {
res.set('Retry-After', String(Math.round(rlRejected.msBeforeNext / 1000)) || 1);
res.status(429).send('Too Many Requests');
}
}
}
if (user.isLoggedIn) {
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
// Reset on successful authorisation
await limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey);
}
res.end('authorized');
}
}
}
const app = express();
app.post('/login', async (req, res) => {
try {
await loginRoute(req, res);
} catch (err) {
res.status(500).end();
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment