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", "name": "my-app",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@types/humanize-duration": "^3.27.4",
"@types/validator": "^13.12.3", "@types/validator": "^13.12.3",
"@types/wake_on_lan": "^0.0.33", "@types/wake_on_lan": "^0.0.33",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bits-ui": "^1.3.19", "bits-ui": "^1.3.19",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"humanize-duration": "^3.32.1",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"validator": "^13.15.0", "validator": "^13.15.0",
@ -1522,6 +1524,12 @@
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.14.0", "version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
@ -2068,6 +2076,12 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/import-meta-resolve": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "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" "vite": "^6.2.5"
}, },
"dependencies": { "dependencies": {
"@types/humanize-duration": "^3.27.4",
"@types/validator": "^13.12.3", "@types/validator": "^13.12.3",
"@types/wake_on_lan": "^0.0.33", "@types/wake_on_lan": "^0.0.33",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bits-ui": "^1.3.19", "bits-ui": "^1.3.19",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"humanize-duration": "^3.32.1",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"validator": "^13.15.0", "validator": "^13.15.0",

View File

@ -2,5 +2,8 @@ import { fail } from '@sveltejs/kit';
import type { ZodError } from 'zod'; import type { ZodError } from 'zod';
export const FORBIDDEN = fail(403, { error: 'You do not have permission to do that.' }); 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 SUCCESS = { success: true };
export const PARSE_ERROR = (err: ZodError) => fail(400, { error: err.errors[0].message }); 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 { 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 { users } from '$lib/server/db';
import { createSession } from '$lib/server/sessions'; import { createSession } from '$lib/server/sessions';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import humanizeDuration from 'humanize-duration';
import { z } from 'zod'; import { z } from 'zod';
import type { Actions } from './$types'; 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 = { 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()) { if (!guard.requiresAuth().isFailed()) {
redirect(302, '/dash'); redirect(302, '/dash');
} }
@ -28,8 +70,19 @@ export const actions = {
const user = await users.getByName(parsed.data.username); const user = await users.getByName(parsed.data.username);
if (!user || !bcrypt.compareSync(parsed.data.password, user.password)) { 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, { return fail(403, {
error: 'Could not sign in. Please verify your username/password are correct.', 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'); redirect(302, '/dash');
}, },
} satisfies Actions; } satisfies Actions;