Initial commit: thbd-portal, duplicated from elmi-portal
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
This commit is contained in:
commit
579528edf7
197 files changed
+25022
No files matched your search
@@ -0,0 +1,63 @@
|
||||
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 } });
|
||||
}
|
||||
Reference in new issue
Block a user