fix: switch telegram connect to short token and single-window redirect

This commit is contained in:
Ruslan Bakiev
2026-02-22 09:20:22 +07:00
parent 25f7f8dfb4
commit 5679f22f7f
4 changed files with 44 additions and 88 deletions

View File

@@ -646,7 +646,6 @@ async function loadTelegramConnectStatus() {
async function startTelegramBusinessConnect() { async function startTelegramBusinessConnect() {
if (telegramConnectBusy.value) return; if (telegramConnectBusy.value) return;
telegramConnectBusy.value = true; telegramConnectBusy.value = true;
const popup = process.client ? window.open("about:blank", "_blank", "noopener,noreferrer") : null;
try { try {
const result = await $fetch<{ const result = await $fetch<{
ok: boolean; ok: boolean;
@@ -657,14 +656,9 @@ async function startTelegramBusinessConnect() {
telegramConnectStatus.value = result?.status ?? "pending_link"; telegramConnectStatus.value = result?.status ?? "pending_link";
telegramConnectUrl.value = String(result?.connectUrl ?? "").trim(); telegramConnectUrl.value = String(result?.connectUrl ?? "").trim();
if (telegramConnectUrl.value && process.client) { if (telegramConnectUrl.value && process.client) {
if (popup) { window.location.href = telegramConnectUrl.value;
popup.location.href = telegramConnectUrl.value;
} else {
window.open(telegramConnectUrl.value, "_blank", "noopener,noreferrer");
}
} }
} catch { } catch {
popup?.close();
telegramConnectStatus.value = "not_connected"; telegramConnectStatus.value = "not_connected";
} finally { } finally {
telegramConnectBusy.value = false; telegramConnectBusy.value = false;

View File

@@ -1,6 +1,5 @@
import { readBody } from "h3"; import { readBody } from "h3";
import { prisma } from "../../../../../utils/prisma"; import { prisma } from "../../../../../utils/prisma";
import { verifyLinkToken } from "../../../../../utils/telegramBusinessConnect";
type CompleteBody = { type CompleteBody = {
token?: string; token?: string;
@@ -13,15 +12,9 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "token is required" }); throw createError({ statusCode: 400, statusMessage: "token is required" });
} }
const payload = verifyLinkToken(token); const pendingId = `pending:${token}`;
if (!payload) {
return { ok: false, status: "invalid_or_expired_token" };
}
const pendingId = `pending:${payload.nonce}`;
const pending = await prisma.telegramBusinessConnection.findFirst({ const pending = await prisma.telegramBusinessConnection.findFirst({
where: { where: {
teamId: payload.teamId,
businessConnectionId: pendingId, businessConnectionId: pendingId,
}, },
}); });
@@ -31,6 +24,11 @@ export default defineEventHandler(async (event) => {
} }
const raw = (pending.rawJson ?? {}) as any; 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() : ""; const telegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId).trim() : "";
if (!telegramUserId) { if (!telegramUserId) {
return { ok: false, status: "awaiting_telegram_start" }; return { ok: false, status: "awaiting_telegram_start" };
@@ -41,12 +39,12 @@ export default defineEventHandler(async (event) => {
prisma.telegramBusinessConnection.upsert({ prisma.telegramBusinessConnection.upsert({
where: { where: {
teamId_businessConnectionId: { teamId_businessConnectionId: {
teamId: payload.teamId, teamId: pending.teamId,
businessConnectionId: linkedConnectionId, businessConnectionId: linkedConnectionId,
}, },
}, },
create: { create: {
teamId: payload.teamId, teamId: pending.teamId,
businessConnectionId: linkedConnectionId, businessConnectionId: linkedConnectionId,
isEnabled: true, isEnabled: true,
canReply: true, canReply: true,
@@ -55,7 +53,7 @@ export default defineEventHandler(async (event) => {
mode: "token_link", mode: "token_link",
linkedAt: new Date().toISOString(), linkedAt: new Date().toISOString(),
telegramUserId, telegramUserId,
tokenNonce: payload.nonce, tokenNonce: token,
}, },
}, },
update: { update: {
@@ -66,7 +64,7 @@ export default defineEventHandler(async (event) => {
mode: "token_link", mode: "token_link",
linkedAt: new Date().toISOString(), linkedAt: new Date().toISOString(),
telegramUserId, telegramUserId,
tokenNonce: payload.nonce, tokenNonce: token,
}, },
}, },
}), }),

View File

@@ -5,7 +5,6 @@ import {
extractLinkTokenFromStartText, extractLinkTokenFromStartText,
getBusinessConnectionFromUpdate, getBusinessConnectionFromUpdate,
getTelegramChatIdFromUpdate, getTelegramChatIdFromUpdate,
verifyLinkToken,
} from "../../../../utils/telegramBusinessConnect"; } from "../../../../utils/telegramBusinessConnect";
function hasValidSecret(event: any) { function hasValidSecret(event: any) {
@@ -78,8 +77,13 @@ export default defineEventHandler(async (event) => {
} }
if (linkToken) { if (linkToken) {
const payload = verifyLinkToken(linkToken); const pendingId = `pending:${linkToken}`;
if (!payload) { const pending = await prisma.telegramBusinessConnection.findFirst({
where: {
businessConnectionId: pendingId,
},
});
if (!pending) {
if (startChatId) { if (startChatId) {
void telegramBotApi("sendMessage", { void telegramBotApi("sendMessage", {
chat_id: startChatId, chat_id: startChatId,
@@ -90,20 +94,31 @@ export default defineEventHandler(async (event) => {
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" }; 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; const chatId = startChatId;
await prisma.telegramBusinessConnection.updateMany({ await prisma.telegramBusinessConnection.updateMany({
where: { where: {
teamId: payload.teamId, teamId: pending.teamId,
businessConnectionId: pendingId, businessConnectionId: pendingId,
}, },
data: { data: {
rawJson: { rawJson: {
state: "pending_business_connection", state: "pending_business_connection",
link: { link: {
nonce: payload.nonce, ...(rawPending?.link ?? {}),
exp: payload.exp,
linkedAt: nowIso, linkedAt: nowIso,
telegramUserId: chatId, telegramUserId: chatId,
chatId, chatId,

View File

@@ -1,38 +1,13 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { randomBytes } from "node:crypto";
export type LinkTokenPayloadV1 = { export type LinkTokenPayloadV1 = {
v: 1; v: 1;
teamId: string;
userId: string;
nonce: string; nonce: string;
exp: number; exp: number;
}; };
const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60); 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() { export function requireBotUsername() {
const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, ""); const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, "");
if (!botUsername) { if (!botUsername) {
@@ -41,52 +16,26 @@ export function requireBotUsername() {
return botUsername; return botUsername;
} }
function sign(input: string, secret: string) {
return createHmac("sha256", secret).update(input).digest();
}
export function issueLinkToken(input: { teamId: string; userId: string }) { 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 = { const payload: LinkTokenPayloadV1 = {
v: 1, v: 1,
teamId: input.teamId, nonce: token,
userId: input.userId,
nonce: randomBytes(12).toString("hex"),
exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC), 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 }; return { token, payload };
} }
export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null { export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null {
const raw = String(token || "").trim(); const raw = String(token || "").trim();
if (!raw) return null; if (!/^[a-f0-9]{24}$/.test(raw)) return null;
return {
const dotIdx = raw.indexOf("."); v: 1,
if (dotIdx <= 0 || dotIdx >= raw.length - 1) return null; nonce: raw,
exp: 0,
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;
}
} }
export function extractLinkTokenFromStartText(text: string) { export function extractLinkTokenFromStartText(text: string) {