import { env } from '$env/dynamic/private'; 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 }, 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'); } const schema = z.object({ username: z.string({ message: 'Username is required.' }).trim(), password: z.string({ message: 'Password is required.' }), }); const data = await request.formData(); const parsed = schema.safeParse(Object.fromEntries(data.entries())); if (!parsed.success) { return PARSE_ERROR(parsed.error); } 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, }); } cookies.set( 'session', createSession({ userAgent: request.headers.get('user-agent') ?? 'UNKNOWN', userId: user.id, }), { path: '/', httpOnly: true, secure: env.USE_SECURE != 'false', sameSite: true, maxAge: parseInt(env.SESSION_LIFETIME) || 86400, }, ); clearTimeout(limit?.timeout); rateLimits.delete(ip); redirect(302, '/dash'); }, } satisfies Actions;