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