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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user