Last active
November 10, 2023 16:57
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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