По фоновой проверке безопасности: mode insensitive превращался в ILIKE, и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем регистре (0 из 1426 с заглавными), вход нормализуется. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
53 lines
2.7 KiB
TypeScript
53 lines
2.7 KiB
TypeScript
/**
|
|
* Проверка «у этой почты есть действующий доступ к курсу» для внутренних
|
|
* вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель
|
|
* Second Brain Vault оставляет почту, бот получает ключ входа, только если
|
|
* доступ к курсу действительно есть.
|
|
*
|
|
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
|
|
*/
|
|
|
|
import { timingSafeEqual } from "crypto";
|
|
|
|
export type AccessCheckInput = { email: string; courseSlug: string };
|
|
|
|
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
|
|
if (!body || typeof body !== "object") return null;
|
|
const b = body as Record<string, unknown>;
|
|
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
|
|
const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : "";
|
|
if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) {
|
|
return null;
|
|
}
|
|
return { email, courseSlug };
|
|
}
|
|
|
|
/**
|
|
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
|
|
* пользователя с ЭТОЙ почтой.
|
|
*
|
|
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
|
|
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
|
|
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
|
|
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
|
|
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
|
|
*/
|
|
export function accessWhere(input: AccessCheckInput, now: Date) {
|
|
return {
|
|
user: {
|
|
email: input.email,
|
|
NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] },
|
|
},
|
|
course: { slug: input.courseSlug },
|
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
|
};
|
|
}
|
|
|
|
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
|
|
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
|
|
if (!secret || !header) return false;
|
|
const a = Buffer.from(header);
|
|
const b = Buffer.from(secret);
|
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
}
|