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