111 lines
3.0 KiB
TypeScript
111 lines
3.0 KiB
TypeScript
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<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 }, 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,
|
|
ip,
|
|
}),
|
|
{
|
|
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;
|