Rebranded for Thunderbird Portal (THBD): numbering prefix EL->TH, all "ELMI"/"ЭЛМИ" UI strings genericized, ELMI's real contract templates and company legal/bank details removed (not applicable to this company), fresh VAPID keypair and encryption key, fresh single-migration schema history. Same architecture as elmi-portal: Web Push notifications, unread-chat app badge, cookie-session auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
64 lines
2.2 KiB
TypeScript
64 lines
2.2 KiB
TypeScript
export const runtime = "nodejs";
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db/client";
|
|
import { verifyPassword } from "@/lib/auth/password";
|
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
|
|
const attempts = new Map<string, { count: number; resetAt: number }>();
|
|
const MAX_ATTEMPTS = 10;
|
|
const WINDOW_MS = 15 * 60 * 1000;
|
|
|
|
function isRateLimited(key: string): boolean {
|
|
const now = Date.now();
|
|
const entry = attempts.get(key);
|
|
if (!entry || entry.resetAt < now) {
|
|
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
|
return false;
|
|
}
|
|
entry.count += 1;
|
|
return entry.count > MAX_ATTEMPTS;
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const body = await request.json().catch(() => null);
|
|
const parsed = loginSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
|
|
}
|
|
|
|
const email = parsed.data.email.toLowerCase().trim();
|
|
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
if (isRateLimited(`${ip}:${email}`)) {
|
|
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
|
|
}
|
|
|
|
const user = await db.query.users.findFirst({
|
|
where: (u, { eq }) => eq(u.email, email),
|
|
});
|
|
|
|
// Always run verifyPassword (even against a placeholder hash) so the
|
|
// response timing doesn't reveal whether the email exists.
|
|
const ok = await verifyPassword(
|
|
user?.passwordHash ??
|
|
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
|
|
parsed.data.password,
|
|
);
|
|
|
|
if (!user || !ok) {
|
|
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
|
|
}
|
|
|
|
const token = await createSession(user.id);
|
|
await setSessionCookie(token);
|
|
|
|
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
|
}
|