From 5679f22f7f2fec2c172d079cc3d05c63391e4168 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 22 Feb 2026 09:20:22 +0700 Subject: [PATCH] fix: switch telegram connect to short token and single-window redirect --- frontend/app.vue | 8 +- .../business/connect/complete.post.ts | 22 +++--- .../omni/telegram/business/webhook.post.ts | 29 ++++++-- .../server/utils/telegramBusinessConnect.ts | 73 +++---------------- 4 files changed, 44 insertions(+), 88 deletions(-) diff --git a/frontend/app.vue b/frontend/app.vue index 52ce0c0..ab9cf6b 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -646,7 +646,6 @@ async function loadTelegramConnectStatus() { async function startTelegramBusinessConnect() { if (telegramConnectBusy.value) return; telegramConnectBusy.value = true; - const popup = process.client ? window.open("about:blank", "_blank", "noopener,noreferrer") : null; try { const result = await $fetch<{ ok: boolean; @@ -657,14 +656,9 @@ async function startTelegramBusinessConnect() { telegramConnectStatus.value = result?.status ?? "pending_link"; telegramConnectUrl.value = String(result?.connectUrl ?? "").trim(); if (telegramConnectUrl.value && process.client) { - if (popup) { - popup.location.href = telegramConnectUrl.value; - } else { - window.open(telegramConnectUrl.value, "_blank", "noopener,noreferrer"); - } + window.location.href = telegramConnectUrl.value; } } catch { - popup?.close(); telegramConnectStatus.value = "not_connected"; } finally { telegramConnectBusy.value = false; diff --git a/frontend/server/api/omni/telegram/business/connect/complete.post.ts b/frontend/server/api/omni/telegram/business/connect/complete.post.ts index e1e6c8f..940ca54 100644 --- a/frontend/server/api/omni/telegram/business/connect/complete.post.ts +++ b/frontend/server/api/omni/telegram/business/connect/complete.post.ts @@ -1,6 +1,5 @@ import { readBody } from "h3"; import { prisma } from "../../../../../utils/prisma"; -import { verifyLinkToken } from "../../../../../utils/telegramBusinessConnect"; type CompleteBody = { token?: string; @@ -13,15 +12,9 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: "token is required" }); } - const payload = verifyLinkToken(token); - if (!payload) { - return { ok: false, status: "invalid_or_expired_token" }; - } - - const pendingId = `pending:${payload.nonce}`; + const pendingId = `pending:${token}`; const pending = await prisma.telegramBusinessConnection.findFirst({ where: { - teamId: payload.teamId, businessConnectionId: pendingId, }, }); @@ -31,6 +24,11 @@ export default defineEventHandler(async (event) => { } const raw = (pending.rawJson ?? {}) as any; + const exp = Number(raw?.link?.exp ?? 0); + if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) { + return { ok: false, status: "invalid_or_expired_token" }; + } + const telegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId).trim() : ""; if (!telegramUserId) { return { ok: false, status: "awaiting_telegram_start" }; @@ -41,12 +39,12 @@ export default defineEventHandler(async (event) => { prisma.telegramBusinessConnection.upsert({ where: { teamId_businessConnectionId: { - teamId: payload.teamId, + teamId: pending.teamId, businessConnectionId: linkedConnectionId, }, }, create: { - teamId: payload.teamId, + teamId: pending.teamId, businessConnectionId: linkedConnectionId, isEnabled: true, canReply: true, @@ -55,7 +53,7 @@ export default defineEventHandler(async (event) => { mode: "token_link", linkedAt: new Date().toISOString(), telegramUserId, - tokenNonce: payload.nonce, + tokenNonce: token, }, }, update: { @@ -66,7 +64,7 @@ export default defineEventHandler(async (event) => { mode: "token_link", linkedAt: new Date().toISOString(), telegramUserId, - tokenNonce: payload.nonce, + tokenNonce: token, }, }, }), diff --git a/frontend/server/api/omni/telegram/business/webhook.post.ts b/frontend/server/api/omni/telegram/business/webhook.post.ts index 1fc3c3f..1c2fefc 100644 --- a/frontend/server/api/omni/telegram/business/webhook.post.ts +++ b/frontend/server/api/omni/telegram/business/webhook.post.ts @@ -5,7 +5,6 @@ import { extractLinkTokenFromStartText, getBusinessConnectionFromUpdate, getTelegramChatIdFromUpdate, - verifyLinkToken, } from "../../../../utils/telegramBusinessConnect"; function hasValidSecret(event: any) { @@ -78,8 +77,13 @@ export default defineEventHandler(async (event) => { } if (linkToken) { - const payload = verifyLinkToken(linkToken); - if (!payload) { + const pendingId = `pending:${linkToken}`; + const pending = await prisma.telegramBusinessConnection.findFirst({ + where: { + businessConnectionId: pendingId, + }, + }); + if (!pending) { if (startChatId) { void telegramBotApi("sendMessage", { chat_id: startChatId, @@ -90,20 +94,31 @@ export default defineEventHandler(async (event) => { return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" }; } - const pendingId = `pending:${payload.nonce}`; + const rawPending = (pending.rawJson ?? {}) as any; + const exp = Number(rawPending?.link?.exp ?? 0); + if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) { + if (startChatId) { + void telegramBotApi("sendMessage", { + chat_id: startChatId, + text: "Ссылка привязки истекла. Вернись в CRM и нажми Connect заново.", + reply_markup: crmConnectButton(), + }).catch(() => {}); + } + return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" }; + } + const chatId = startChatId; await prisma.telegramBusinessConnection.updateMany({ where: { - teamId: payload.teamId, + teamId: pending.teamId, businessConnectionId: pendingId, }, data: { rawJson: { state: "pending_business_connection", link: { - nonce: payload.nonce, - exp: payload.exp, + ...(rawPending?.link ?? {}), linkedAt: nowIso, telegramUserId: chatId, chatId, diff --git a/frontend/server/utils/telegramBusinessConnect.ts b/frontend/server/utils/telegramBusinessConnect.ts index 535be47..d0ad346 100644 --- a/frontend/server/utils/telegramBusinessConnect.ts +++ b/frontend/server/utils/telegramBusinessConnect.ts @@ -1,38 +1,13 @@ -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { randomBytes } from "node:crypto"; export type LinkTokenPayloadV1 = { v: 1; - teamId: string; - userId: string; nonce: string; exp: number; }; const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60); -function base64UrlEncode(input: Buffer | string) { - return Buffer.from(input) - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - -function base64UrlDecode(input: string) { - const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); - const pad = normalized.length % 4; - const padded = pad === 0 ? normalized : normalized + "=".repeat(4 - pad); - return Buffer.from(padded, "base64"); -} - -export function requireLinkSecret() { - const secret = String(process.env.TELEGRAM_LINK_SECRET || process.env.TELEGRAM_BOT_TOKEN || "").trim(); - if (!secret) { - throw createError({ statusCode: 500, statusMessage: "TELEGRAM_LINK_SECRET or TELEGRAM_BOT_TOKEN is required" }); - } - return secret; -} - export function requireBotUsername() { const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, ""); if (!botUsername) { @@ -41,52 +16,26 @@ export function requireBotUsername() { return botUsername; } -function sign(input: string, secret: string) { - return createHmac("sha256", secret).update(input).digest(); -} - export function issueLinkToken(input: { teamId: string; userId: string }) { - const secret = requireLinkSecret(); + void input; + // Telegram deep-link `start` parameter is limited, keep token very short. + const token = randomBytes(12).toString("hex"); const payload: LinkTokenPayloadV1 = { v: 1, - teamId: input.teamId, - userId: input.userId, - nonce: randomBytes(12).toString("hex"), + nonce: token, exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC), }; - const payloadRaw = JSON.stringify(payload); - const payloadEncoded = base64UrlEncode(payloadRaw); - const sig = base64UrlEncode(sign(payloadEncoded, secret)); - const token = `${payloadEncoded}.${sig}`; return { token, payload }; } export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null { const raw = String(token || "").trim(); - if (!raw) return null; - - const dotIdx = raw.indexOf("."); - if (dotIdx <= 0 || dotIdx >= raw.length - 1) return null; - - const payloadEncoded = raw.slice(0, dotIdx); - const sigEncoded = raw.slice(dotIdx + 1); - - try { - const secret = requireLinkSecret(); - const expected = sign(payloadEncoded, secret); - const actual = base64UrlDecode(sigEncoded); - if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { - return null; - } - - const payload = JSON.parse(base64UrlDecode(payloadEncoded).toString("utf8")) as LinkTokenPayloadV1; - if (!payload || payload.v !== 1) return null; - if (!payload.teamId || !payload.userId || !payload.nonce || !payload.exp) return null; - if (Math.floor(Date.now() / 1000) > payload.exp) return null; - return payload; - } catch { - return null; - } + if (!/^[a-f0-9]{24}$/.test(raw)) return null; + return { + v: 1, + nonce: raw, + exp: 0, + }; } export function extractLinkTokenFromStartText(text: string) {