diff --git a/package-lock.json b/package-lock.json index 974ed5e..04076e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2495be4..da69b9f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/server/commonResponses.ts b/src/lib/server/commonResponses.ts index ab06acf..45be44b 100644 --- a/src/lib/server/commonResponses.ts +++ b/src/lib/server/commonResponses.ts @@ -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 }); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 36af376..24bbb5f 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -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(); + +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;