fix: switch telegram connect to short token and single-window redirect
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user