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/sql';
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
31// Helper to parse clearance cookie from headers
32function getClearanceCookie(cookieHeader: string | null): string | null {
33 if (!cookieHeader) return null;
34 // Use a case-insensitive regex to match either "cf-clearance" or "cf_clearance"
35 const regex = /cf[-_]clearance=([^;]+)/i;
36 const match = cookieHeader.match(regex);
37 return match ? match[1] : null;
38}
39
40export const POST: RequestHandler = async ({ request, cookies }) => {
41 log.debug('New login attempt received');
42
43 if (request.method !== 'POST') {
44 log.warn('Invalid method used', { method: request.method });
45 return new Response(
46 JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError),
47 { status: 405 }
48 );
49 }
50
51 // Get the IP address for rate limiting and Turnstile validation
52 const ip = request.headers.get('x-forwarded-for') || 'unknown';
53 const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.');
54 const now = Date.now();
55 const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
56
57 if (attemptData.count >= MAX_ATTEMPTS) {
58 const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000;
59 log.warn('Rate limit exceeded', { maskedIp, delay });
60 await sleep(delay);
61 }
62
63 let body: unknown;
64 try {
65 body = await request.json();
66 } catch {
67 log.warn('Invalid JSON payload received');
68 return new Response(
69 JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError),
70 { status: 400 }
71 );
72 }
73
74 // Extract cookie header and clearance cookie BEFORE validating input.
75 const cookieHeader = request.headers.get('cookie');
76 const clearanceCookie = getClearanceCookie(cookieHeader);
77
78 // Note: Mark turnstileToken as optional if a valid clearance cookie exists.
79 const { username, password, turnstileToken } = body as {
80 username: string;
81 password: string;
82 turnstileToken?: string;
83 };
84
85 if (
86 typeof username !== 'string' ||
87 typeof password !== 'string' ||
88 (!clearanceCookie && typeof turnstileToken !== 'string')
89 ) {
90 log.warn('Missing or invalid input fields', {
91 hasUsername: typeof username === 'string',
92 hasPassword: typeof password === 'string',
93 hasTurnstileToken: typeof turnstileToken === 'string'
94 });
95 return new Response(
96 JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError),
97 { status: 400 }
98 );
99 }
100
101 // Check for clearance cookie before validating Turnstile token.
102 let isValidTurnstile = false;
103 if (clearanceCookie) {
104 log.debug('Clearance cookie detected; bypassing token challenge.');
105 isValidTurnstile = true;
106 } else {
107 isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip);
108 }
109
110 if (!isValidTurnstile) {
111 log.warn('Invalid Turnstile token', { maskedIp });
112 return new Response(
113 JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError),
114 { status: 400 }
115 );
116 }
117
118 // Find user by username
119 const user = await db.query.users.findFirst({
120 where: eq(users.nickname, username.trim())
121 });
122
123 if (!user) {
124 attemptData.count++;
125 attemptData.lastAttempt = now;
126 loginAttempts.set(ip, attemptData);
127 log.warn('Login failed - user not found', {
128 maskedIp,
129 attemptCount: attemptData.count,
130 username: username.trim()
131 });
132 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
133 return new Response(JSON.stringify({
134 error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
135 } as LoginResponseError), { status: 401 });
136 }
137
138 // Verify password
139 try {
140 const isValid = await verifyPassword(password.trim(), user.password);
141 if (!isValid) {
142 attemptData.count++;
143 attemptData.lastAttempt = now;
144 loginAttempts.set(ip, attemptData);
145 log.warn('Login failed - invalid password', {
146 maskedIp,
147 attemptCount: attemptData.count,
148 userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`
149 });
150 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
151 return new Response(JSON.stringify({
152 error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}`
153 } as LoginResponseError), { status: 401 });
154 }
155 } catch (error) {
156 log.error('Error verifying password', { error });
157 return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
158 }
159
160 // Reset login attempt data on success
161 loginAttempts.delete(ip);
162
163 // Generate session token and create session
164 const token = generateSessionToken();
165 const session = await createSession(token, user.id);
166
167 // Update user status to online
168 await db.update(users)
169 .set({
170 status: 'online',
171 lastSeen: Date.now()
172 })
173 .where(eq(users.id, user.id));
174
175 // Set session cookie
176 setSessionTokenCookie({ cookies }, token, session.expiresAt);
177
178 log.info('Login successful', {
179 userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`,
180 expiresAt: new Date(session.expiresAt).toISOString()
181 });
182
183 return new Response(JSON.stringify({
184 success: true,
185 user: createSafeUser(user)
186 } as LoginResponseSuccess), {
187 status: 200
188 });
189};