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