feat: add login rate limiting - closes #4
This commit is contained in:
parent
a55a2992fe
commit
7b45197aa7
14
package-lock.json
generated
14
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user