1import type { RequestHandler } from '@sveltejs/kit';
2import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3import db from '$lib/db/db.server';
4import { users } from '$lib/db/schema';
5import { eq } from 'drizzle-orm';
6import { verifyPassword } from '$lib/utils/password';
7import { generateSessionToken, createSession } from '$lib/api/session.server';
8import { setSessionTokenCookie } from '$lib/api/session.cookie';
9import { createSafeUser } from '$lib/types/chat';
10import { createLogger } from '$lib/utils/logger.server';
11import { validateTurnstileToken } from '$lib/utils/turnstile.server';
12
13const log = createLogger('login-server');
14
15// In-memory map to track failed login attempts per IP
16const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
17const MAX_ATTEMPTS = 3;
18const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
19
20// Cleanup old entries every hour
21setInterval(() => {
22 const now = Date.now();
23 const ONE_HOUR = 60 * 60 * 1000;
24 for (const [ip, data] of loginAttempts.entries()) {
25 if (now - data.lastAttempt > ONE_HOUR) {
26 loginAttempts.delete(ip);
27 }
28 }
29}, 60 * 60 * 1000);
30
31export const POST: RequestHandler = async ({ request, cookies }) => {
32 log.debug('New login attempt received');
33
34 if (request.method !== 'POST') {
35 log.warn('Invalid method used', { method: request.method });
36 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
37 }
38
39 // Get the IP address for rate limiting and Turnstile validation
40 const ip = request.headers.get('x-forwarded-for') || 'unknown';
41 const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
42 const now = Date.now();
43 const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
44
45 // If the user has failed three or more times, apply exponential backoff
46 if (attemptData.count >= MAX_ATTEMPTS) {
47 const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; // delay in milliseconds
48 log.warn('Rate limit exceeded', { maskedIp, delay });
49 await sleep(delay);
50 }
51
52 let body: unknown;
53 try {
54 body = await request.json();
55 } catch {
56 log.warn('Invalid JSON payload received');
57 return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 });
58 }
59
60 const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string };
61
62 if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') {
63 log.warn('Missing or invalid input fields', {
64 hasUsername: typeof username === 'string',
65 hasPassword: typeof password === 'string',
66 hasTurnstileToken: typeof turnstileToken === 'string'
67 });
68 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
69 }
70
71 // Validate Turnstile token
72 const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
73 if (!isValidTurnstile) {
74 log.warn('Invalid Turnstile token', { maskedIp });
75 return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 });
76 }
77
78 // Find user by username
79 const user = await db.query.users.findFirst({
80 where: eq(users.nickname, username.trim())
81 });
82
83 if (!user) {
84 attemptData.count++;
85 attemptData.lastAttempt = now;
86 loginAttempts.set(ip, attemptData);
87 log.warn('Login failed - user not found', {
88 maskedIp,
89 attemptCount: attemptData.count,
90 username: username.trim()
91 });
92 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
93 return new Response(JSON.stringify({
94 error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
95 } as LoginResponseError), { status: 401 });
96 }
97
98 // Verify password
99 try {
100 const isValid = await verifyPassword(password.trim(), user.password);
101 if (!isValid) {
102 attemptData.count++;
103 attemptData.lastAttempt = now;
104 loginAttempts.set(ip, attemptData);
105 log.warn('Login failed - invalid password', {
106 maskedIp,
107 attemptCount: attemptData.count,
108 userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`
109 });
110 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
111 return new Response(JSON.stringify({
112 error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
113 } as LoginResponseError), { status: 401 });
114 }
115 } catch (error) {
116 log.error('Error verifying password', { error });
117 return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
118 }
119
120 // Reset login attempt data on success
121 loginAttempts.delete(ip);
122
123 // Generate session token and create session
124 const token = generateSessionToken();
125 const session = await createSession(token, user.id);
126
127 // Update user status to online
128 await db.update(users)
129 .set({
130 status: 'online',
131 lastSeen: Date.now()
132 })
133 .where(eq(users.id, user.id));
134
135 // Set session cookie
136 setSessionTokenCookie({ cookies }, token, session.expiresAt);
137
138 log.info('Login successful', {
139 userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`,
140 expiresAt: new Date(session.expiresAt).toISOString()
141 });
142
143 return new Response(JSON.stringify({
144 success: true,
145 user: createSafeUser(user)
146 } as LoginResponseSuccess), {
147 status: 200
148 });
149};