1import type { RequestHandler } from '@sveltejs/kit';
2import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3import { users } from '$lib/db/convex.server';
4import { verifyPassword } from '$lib/utils/password';
5import { generateSessionToken, createSession } from '$lib/api/session.server';
6import { setSessionTokenCookie } from '$lib/api/session.cookie';
7import { createSafeUser } from '$lib/types/chat';
8import { createLogger } from '$lib/utils/logger.server';
9import { loginSchema } from '$lib/validation/password';
10import { z } from 'zod';
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 ONE_HOUR = 60 * 60 * 1000;
18const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
19
20// Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope
21let lastCleanup = 0;
22function cleanupOldAttempts() {
23 const now = Date.now();
24 if (now - lastCleanup < ONE_HOUR) return;
25 lastCleanup = now;
26
27 for (const [ip, data] of loginAttempts.entries()) {
28 if (now - data.lastAttempt > ONE_HOUR) {
29 loginAttempts.delete(ip);
30 }
31 }
32}
33
34export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
35 log.debug('New login attempt received');
36
37 // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
38 cleanupOldAttempts();
39
40 if (request.method !== 'POST') {
41 log.warn('Invalid method used', { method: request.method });
42 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), {
43 status: 405
44 });
45 }
46
47 // Get the IP address for rate limiting
48 const ip = getClientAddress();
49 const maskedIp = ip
50 .split('.')
51 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
52 .join('.');
53 const now = Date.now();
54 const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
55
56 if (attemptData.count >= MAX_ATTEMPTS) {
57 const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000;
58 log.warn('Rate limit exceeded', { maskedIp, delay });
59 await sleep(delay);
60 }
61
62 let body: unknown;
63 try {
64 body = await request.json();
65 } catch {
66 log.warn('Invalid JSON payload received');
67 return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), {
68 status: 400
69 });
70 }
71
72 const { username, password } = body as {
73 username: string;
74 password: string;
75 };
76
77 // Validate input using Zod schema
78 try {
79 loginSchema.parse({ username, password });
80 } catch (err) {
81 const zodError = err as z.ZodError;
82 const errorMessage = zodError.issues?.[0]?.message || 'Invalid input data';
83 log.warn('Login validation failed', { error: errorMessage });
84 return new Response(JSON.stringify({ error: errorMessage } as LoginResponseError), {
85 status: 400
86 });
87 }
88
89 // Find user by username using Convex
90 const user = await users.getByNickname(username.trim());
91
92 if (!user) {
93 attemptData.count++;
94 attemptData.lastAttempt = now;
95 loginAttempts.set(ip, attemptData);
96 log.warn('Login failed - user not found', {
97 maskedIp,
98 attemptCount: attemptData.count,
99 username: username.trim()
100 });
101 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
102 return new Response(
103 JSON.stringify({
104 error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}`
105 } as LoginResponseError),
106 { status: 401 }
107 );
108 }
109
110 // Use native Convex _id
111 const userId = user._id;
112
113 // Verify password
114 try {
115 const isValid = await verifyPassword(password, user.password);
116 if (!isValid) {
117 attemptData.count++;
118 attemptData.lastAttempt = now;
119 loginAttempts.set(ip, attemptData);
120 log.warn('Login failed - invalid password', {
121 maskedIp,
122 attemptCount: attemptData.count,
123 userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
124 });
125 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
126 return new Response(
127 JSON.stringify({
128 error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}`
129 } as LoginResponseError),
130 { status: 401 }
131 );
132 }
133 } catch (error) {
134 log.error('Error verifying password', { error });
135 return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), {
136 status: 500
137 });
138 }
139
140 // Reset login attempt data on success
141 loginAttempts.delete(ip);
142
143 // Generate session token and create session
144 const token = generateSessionToken();
145 const session = await createSession(token, userId);
146
147 // Update user status to online using Convex
148 await users.updateStatus(userId, 'online');
149
150 // Set session cookie
151 setSessionTokenCookie({ cookies }, token, session.expiresAt);
152
153 log.info('Login successful', {
154 userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
155 expiresAt: new Date(session.expiresAt).toISOString()
156 });
157
158 // Return user with native Convex ID
159 return new Response(
160 JSON.stringify({
161 success: true,
162 user: createSafeUser({
163 id: userId,
164 nickname: user.nickname,
165 status: 'online',
166 avatarUrl: user.avatarUrl,
167 lastSeen: user.lastSeen,
168 lastReadMentionTimestamp: user.lastReadMentionTimestamp
169 })
170 } as LoginResponseSuccess),
171 {
172 status: 200
173 }
174 );
175};