feat: add login rate limiting - closes #4

This commit is contained in:
axel 2025-04-18 16:38:17 +02:00
parent a55a2992fe
commit 7b45197aa7
4 changed files with 77 additions and 2 deletions

14
package-lock.json generated
View File

@ -8,11 +8,13 @@
"name": "my-app",
"version": "0.0.1",
"dependencies": {
"@types/humanize-duration": "^3.27.4",
"@types/validator": "^13.12.3",
"@types/wake_on_lan": "^0.0.33",
"bcryptjs": "^3.0.2",
"bits-ui": "^1.3.19",
"drizzle-orm": "^0.41.0",
"humanize-duration": "^3.32.1",
"lowdb": "^7.0.1",
"nanoid": "^5.1.5",
"validator": "^13.15.0",
@ -1522,6 +1524,12 @@
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT"
},
"node_modules/@types/humanize-duration": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz",
"integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
@ -2068,6 +2076,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/humanize-duration": {
"version": "3.32.1",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.32.1.tgz",
"integrity": "sha512-inh5wue5XdfObhu/IGEMiA1nUXigSGcaKNemcbLRKa7jXYGDZXr3LoT9pTIzq2hPEbld7w/qv9h+ikWGz8fL1g==",
"license": "Unlicense"
},
"node_modules/import-meta-resolve": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",

View File

@ -30,11 +30,13 @@
"vite": "^6.2.5"
},
"dependencies": {
"@types/humanize-duration": "^3.27.4",
"@types/validator": "^13.12.3",
"@types/wake_on_lan": "^0.0.33",
"bcryptjs": "^3.0.2",
"bits-ui": "^1.3.19",
"drizzle-orm": "^0.41.0",
"humanize-duration": "^3.32.1",
"lowdb": "^7.0.1",
"nanoid": "^5.1.5",
"validator": "^13.15.0",

View File

@ -2,5 +2,8 @@ import { fail } from '@sveltejs/kit';
import type { ZodError } from 'zod';
export const FORBIDDEN = fail(403, { error: 'You do not have permission to do that.' });
export const INTERNAL_ERR = fail(500, {
error: 'An internal error occurred. Please have an administrator look at the application logs.',
});
export const SUCCESS = { success: true };
export const PARSE_ERROR = (err: ZodError) => fail(400, { error: err.errors[0].message });

View File

@ -1,14 +1,56 @@
import { env } from '$env/dynamic/private';
import { PARSE_ERROR } from '$lib/server/commonResponses';
import { INTERNAL_ERR, PARSE_ERROR } from '$lib/server/commonResponses';
import { users } from '$lib/server/db';
import { createSession } from '$lib/server/sessions';
import { fail, redirect } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import humanizeDuration from 'humanize-duration';
import { z } from 'zod';
import type { Actions } from './$types';
type RateLimitData = { attempts: number; blockedUntil: number; timeout: NodeJS.Timeout };
const rateLimits = new Map<string, RateLimitData>();
const delays = [
60, //
60 * 2,
60 * 5,
60 * 15,
60 * 30,
60 * 60,
];
const freeAttempts = 2;
function getBackoff(n: number) {
if (n < freeAttempts) return 0;
return delays[Math.min(n - freeAttempts, delays.length) - 1] * 1000;
}
export const actions = {
default: async ({ cookies, request, locals: { guard } }) => {
default: async ({ cookies, request, locals: { guard }, getClientAddress }) => {
const ip =
env.USE_REVERSE_PROXY != 'false'
? request.headers.get(env.REAL_IP_HEADER)
: getClientAddress();
if (!ip) {
console.error(
'Could not get client IP -- check .env, are the USE_REVERSE_PROXY and REAL_IP_HEADER set correctly?',
);
return INTERNAL_ERR;
}
const limit = rateLimits.get(ip);
if (limit && Date.now() < limit.blockedUntil) {
return fail(429, {
error:
'Too many attempts. Try again in ' +
humanizeDuration(limit.blockedUntil - Date.now(), { round: true }),
});
}
if (!guard.requiresAuth().isFailed()) {
redirect(302, '/dash');
}
@ -28,8 +70,19 @@ export const actions = {
const user = await users.getByName(parsed.data.username);
if (!user || !bcrypt.compareSync(parsed.data.password, user.password)) {
let attempts = limit?.attempts != undefined ? limit.attempts + 1 : 1;
let blockedUntil = Date.now() + getBackoff(attempts);
clearTimeout(limit?.timeout);
rateLimits.set(ip, {
attempts,
blockedUntil,
timeout: setTimeout(() => rateLimits.delete(ip), 86_400_000), // 24h after last attempt, forget about it
});
return fail(403, {
error: 'Could not sign in. Please verify your username/password are correct.',
blockedUntil,
});
}
@ -48,6 +101,9 @@ export const actions = {
},
);
clearTimeout(limit?.timeout);
rateLimits.delete(ip);
redirect(302, '/dash');
},
} satisfies Actions;