Add Telegram Mini App auth flow
This commit is contained in:
97
src/messenger-connections.js
Normal file
97
src/messenger-connections.js
Normal file
@@ -0,0 +1,97 @@
|
||||
function normalizeOptionalText(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function normalizeTelegramProfile(profile) {
|
||||
if (!profile || typeof profile !== 'object') {
|
||||
return {
|
||||
displayName: null,
|
||||
username: null,
|
||||
avatarFileId: null,
|
||||
avatarFileUniqueId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: normalizeOptionalText(profile.displayName),
|
||||
username: normalizeOptionalText(profile.username)?.replace(/^@+/, '') || null,
|
||||
avatarFileId: normalizeOptionalText(profile.avatarFileId),
|
||||
avatarFileUniqueId: normalizeOptionalText(profile.avatarFileUniqueId),
|
||||
};
|
||||
}
|
||||
|
||||
export function profileFromTelegramMiniAppUser(user) {
|
||||
if (!user || typeof user !== 'object') {
|
||||
return normalizeTelegramProfile(null);
|
||||
}
|
||||
|
||||
const firstName = String(user.first_name || '').trim();
|
||||
const lastName = String(user.last_name || '').trim();
|
||||
const displayName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
return normalizeTelegramProfile({
|
||||
displayName,
|
||||
username: user.username,
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) {
|
||||
const normalizedChannelId = String(channelId || '').trim();
|
||||
if (!userId || !type || !normalizedChannelId) {
|
||||
throw new Error('userId, type and channelId are required to connect messenger.');
|
||||
}
|
||||
|
||||
const normalizedProfile = type === 'TELEGRAM'
|
||||
? normalizeTelegramProfile(profile)
|
||||
: normalizeTelegramProfile(null);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.messengerConnection.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
isActive: true,
|
||||
NOT: { channelId: normalizedChannelId },
|
||||
},
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await tx.messengerConnection.updateMany({
|
||||
where: {
|
||||
type,
|
||||
channelId: normalizedChannelId,
|
||||
isActive: true,
|
||||
NOT: { userId },
|
||||
},
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return tx.messengerConnection.upsert({
|
||||
where: {
|
||||
userId_type_channelId: {
|
||||
userId,
|
||||
type,
|
||||
channelId: normalizedChannelId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isActive: true,
|
||||
displayName: normalizedProfile.displayName,
|
||||
username: normalizedProfile.username,
|
||||
avatarFileId: normalizedProfile.avatarFileId,
|
||||
avatarFileUniqueId: normalizedProfile.avatarFileUniqueId,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type,
|
||||
channelId: normalizedChannelId,
|
||||
isActive: true,
|
||||
displayName: normalizedProfile.displayName,
|
||||
username: normalizedProfile.username,
|
||||
avatarFileId: normalizedProfile.avatarFileId,
|
||||
avatarFileUniqueId: normalizedProfile.avatarFileUniqueId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,35 @@ function maskChannel(channelId) {
|
||||
return `${text.slice(0, 3)}***${text.slice(-3)}`;
|
||||
}
|
||||
|
||||
function buildTelegramButton(buttonUrl, buttonText) {
|
||||
const url = String(buttonUrl || '').trim();
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = String(buttonText || '').trim() || 'Открыть кабинет';
|
||||
const miniAppBaseUrl = String(
|
||||
process.env.TELEGRAM_MINI_APP_URL ||
|
||||
process.env.WEB_FRONTEND_URL ||
|
||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||
'',
|
||||
).trim().replace(/\/$/, '');
|
||||
|
||||
if (miniAppBaseUrl && url.startsWith(miniAppBaseUrl)) {
|
||||
return {
|
||||
text,
|
||||
web_app: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendTelegramMessage(channelId, message, options = {}) {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) {
|
||||
@@ -16,6 +45,7 @@ async function sendTelegramMessage(channelId, message, options = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const button = buildTelegramButton(options.buttonUrl, options.buttonText);
|
||||
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -23,14 +53,11 @@ async function sendTelegramMessage(channelId, message, options = {}) {
|
||||
chat_id: channelId,
|
||||
text: message,
|
||||
disable_web_page_preview: true,
|
||||
...(options.buttonUrl
|
||||
...(button
|
||||
? {
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{
|
||||
text: options.buttonText || 'Открыть кабинет',
|
||||
url: options.buttonUrl,
|
||||
},
|
||||
button,
|
||||
]],
|
||||
},
|
||||
}
|
||||
@@ -121,7 +148,7 @@ export async function sendMessengerMessage({ type, channelId, message, buttonUrl
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchToUserConnections(prisma, userId, message) {
|
||||
export async function dispatchToUserConnections(prisma, userId, message, options = {}) {
|
||||
const connections = await prisma.messengerConnection.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -139,6 +166,8 @@ export async function dispatchToUserConnections(prisma, userId, message) {
|
||||
type: connection.type,
|
||||
channelId: connection.channelId,
|
||||
message,
|
||||
buttonUrl: options.buttonUrl,
|
||||
buttonText: options.buttonText,
|
||||
});
|
||||
|
||||
results.push({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isManagerRole,
|
||||
} from './access.js';
|
||||
import { sendLoginCodeEmail } from './mailer.js';
|
||||
import { upsertActiveMessengerConnection } from './messenger-connections.js';
|
||||
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
|
||||
import { dateTimeScalar, jsonScalar } from './scalars.js';
|
||||
import { fetchTelegramConnectionProfile } from './telegram.js';
|
||||
@@ -281,6 +282,33 @@ function formatOrderStatusMessage(order, status, note) {
|
||||
return `Заказ ${order.code} изменил статус: ${status}.${suffix}`;
|
||||
}
|
||||
|
||||
function buildFrontendAppUrl(path) {
|
||||
const baseUrl = String(
|
||||
process.env.TELEGRAM_MINI_APP_URL ||
|
||||
process.env.WEB_FRONTEND_URL ||
|
||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||
'',
|
||||
).trim().replace(/\/$/, '');
|
||||
|
||||
const normalizedPath = String(path || '').trim();
|
||||
if (!baseUrl || !normalizedPath.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${baseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function buildUserOrderPath(orderId, role) {
|
||||
const normalizedOrderId = String(orderId || '').trim();
|
||||
if (!normalizedOrderId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return isManagerRole(role)
|
||||
? `/client-orders/${normalizedOrderId}`
|
||||
: `/orders/${normalizedOrderId}`;
|
||||
}
|
||||
|
||||
async function notifyOrderStakeholders(context, order, status, note) {
|
||||
const recipients = [order.customerId, order.managerId].filter(Boolean);
|
||||
if (!recipients.length) {
|
||||
@@ -289,8 +317,22 @@ async function notifyOrderStakeholders(context, order, status, note) {
|
||||
|
||||
const message = formatOrderStatusMessage(order, status, note);
|
||||
const uniqueRecipients = [...new Set(recipients)];
|
||||
const users = await context.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: uniqueRecipients },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
const userRoleMap = new Map(users.map((user) => [user.id, user.role]));
|
||||
|
||||
await Promise.allSettled(
|
||||
uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message)),
|
||||
uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message, {
|
||||
buttonUrl: buildFrontendAppUrl(buildUserOrderPath(order.id, userRoleMap.get(userId))),
|
||||
buttonText: 'Открыть заказ',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -820,41 +862,11 @@ export const resolvers = {
|
||||
}
|
||||
|
||||
if (login.messengerConnection) {
|
||||
await context.prisma.messengerConnection.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: login.messengerConnection.type,
|
||||
isActive: true,
|
||||
NOT: { channelId: login.messengerConnection.channelId },
|
||||
},
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await context.prisma.messengerConnection.upsert({
|
||||
where: {
|
||||
userId_type_channelId: {
|
||||
userId: user.id,
|
||||
type: login.messengerConnection.type,
|
||||
channelId: login.messengerConnection.channelId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isActive: true,
|
||||
displayName: login.messengerConnection.displayName ?? null,
|
||||
username: login.messengerConnection.username ?? null,
|
||||
avatarFileId: login.messengerConnection.avatarFileId ?? null,
|
||||
avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: login.messengerConnection.type,
|
||||
channelId: login.messengerConnection.channelId,
|
||||
isActive: true,
|
||||
displayName: login.messengerConnection.displayName ?? null,
|
||||
username: login.messengerConnection.username ?? null,
|
||||
avatarFileId: login.messengerConnection.avatarFileId ?? null,
|
||||
avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null,
|
||||
},
|
||||
await upsertActiveMessengerConnection(context.prisma, {
|
||||
userId: user.id,
|
||||
type: login.messengerConnection.type,
|
||||
channelId: login.messengerConnection.channelId,
|
||||
profile: login.messengerConnection,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
118
src/server.js
118
src/server.js
@@ -15,15 +15,18 @@ import {
|
||||
consumeMessengerStartSession,
|
||||
extractAuthTokenFromRequest,
|
||||
hasMessengerStartSession,
|
||||
issueAccessToken,
|
||||
issueTemporaryLoginToken,
|
||||
verifyAccessToken,
|
||||
} from './auth.js';
|
||||
import { canManagerAccessUser, isManagerRole } from './access.js';
|
||||
import { buildContext } from './context.js';
|
||||
import { profileFromTelegramMiniAppUser, normalizeTelegramProfile, upsertActiveMessengerConnection } from './messenger-connections.js';
|
||||
import { sendMessengerMessage } from './messenger.js';
|
||||
import { prisma } from './prisma-client.js';
|
||||
import { resolvers } from './resolvers.js';
|
||||
import { telegramApi, telegramFileUrl } from './telegram.js';
|
||||
import { validateTelegramMiniAppInitData } from './telegram-mini-app.js';
|
||||
|
||||
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
||||
|
||||
@@ -92,16 +95,28 @@ function normalizeRedirectPath(value) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
function normalizeTelegramProfile(profile) {
|
||||
if (!profile || typeof profile !== 'object') {
|
||||
return null;
|
||||
}
|
||||
function presentTelegramMiniAppUser(user) {
|
||||
const firstName = String(user?.first_name || '').trim();
|
||||
const lastName = String(user?.last_name || '').trim();
|
||||
|
||||
return {
|
||||
displayName: String(profile.displayName || '').trim() || null,
|
||||
username: String(profile.username || '').trim().replace(/^@+/, '') || null,
|
||||
avatarFileId: String(profile.avatarFileId || '').trim() || null,
|
||||
avatarFileUniqueId: String(profile.avatarFileUniqueId || '').trim() || null,
|
||||
id: String(user?.id || '').trim(),
|
||||
firstName,
|
||||
lastName: lastName || null,
|
||||
username: String(user?.username || '').trim() || null,
|
||||
languageCode: String(user?.language_code || '').trim() || null,
|
||||
photoUrl: String(user?.photo_url || '').trim() || null,
|
||||
displayName: `${firstName} ${lastName}`.trim() || firstName || 'Пользователь Telegram',
|
||||
};
|
||||
}
|
||||
|
||||
function presentAuthUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
companyId: user.companyId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,6 +165,93 @@ app.post('/auth/messenger-start', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/telegram-mini-app/session', async (req, res) => {
|
||||
let telegram;
|
||||
try {
|
||||
telegram = validateTelegramMiniAppInitData(req.body?.initData);
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = String(telegram.user.id);
|
||||
const connection = await prisma.messengerConnection.findFirst({
|
||||
where: {
|
||||
type: 'TELEGRAM',
|
||||
channelId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!connection?.user) {
|
||||
res.json({
|
||||
ok: true,
|
||||
authenticated: false,
|
||||
telegramUser: presentTelegramMiniAppUser(telegram.user),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertActiveMessengerConnection(prisma, {
|
||||
userId: connection.user.id,
|
||||
type: 'TELEGRAM',
|
||||
channelId,
|
||||
profile: profileFromTelegramMiniAppUser(telegram.user),
|
||||
});
|
||||
|
||||
const session = issueAccessToken(connection.user.id);
|
||||
res.json({
|
||||
ok: true,
|
||||
authenticated: true,
|
||||
accessToken: session.accessToken,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
user: presentAuthUser(connection.user),
|
||||
telegramUser: presentTelegramMiniAppUser(telegram.user),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/telegram-mini-app/connect', async (req, res) => {
|
||||
const user = await resolveAuthenticatedUserFromRequest(req);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Authentication required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let telegram;
|
||||
try {
|
||||
telegram = validateTelegramMiniAppInitData(req.body?.initData);
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await upsertActiveMessengerConnection(prisma, {
|
||||
userId: user.id,
|
||||
type: 'TELEGRAM',
|
||||
channelId: telegram.user.id,
|
||||
profile: profileFromTelegramMiniAppUser(telegram.user),
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
userId: connection.userId,
|
||||
type: connection.type,
|
||||
channelId: connection.channelId,
|
||||
displayName: connection.displayName,
|
||||
username: connection.username,
|
||||
avatarAvailable: Boolean(connection.avatarFileId),
|
||||
isActive: connection.isActive,
|
||||
},
|
||||
telegramUser: presentTelegramMiniAppUser(telegram.user),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/bot/messenger-login', async (req, res) => {
|
||||
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
||||
const providedToken = req.body?.token;
|
||||
|
||||
90
src/telegram-mini-app.js
Normal file
90
src/telegram-mini-app.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60);
|
||||
|
||||
function requireTelegramBotToken() {
|
||||
const token = String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
|
||||
if (!token) {
|
||||
throw new Error('TELEGRAM_BOT_TOKEN is not configured.');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left || ''), 'hex');
|
||||
const rightBuffer = Buffer.from(String(right || ''), 'hex');
|
||||
|
||||
if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function parseTelegramUser(rawUser) {
|
||||
if (!rawUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawUser);
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(parsed.id),
|
||||
first_name: String(parsed.first_name || '').trim(),
|
||||
last_name: String(parsed.last_name || '').trim() || null,
|
||||
username: String(parsed.username || '').trim() || null,
|
||||
language_code: String(parsed.language_code || '').trim() || null,
|
||||
photo_url: String(parsed.photo_url || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTelegramMiniAppInitData(initDataRaw) {
|
||||
const initData = String(initDataRaw || '').trim();
|
||||
if (!initData) {
|
||||
throw new Error('Telegram initData is required.');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(initData);
|
||||
const receivedHash = String(params.get('hash') || '').trim().toLowerCase();
|
||||
if (!receivedHash) {
|
||||
throw new Error('Telegram initData hash is missing.');
|
||||
}
|
||||
|
||||
const authDate = Number(params.get('auth_date'));
|
||||
if (!Number.isFinite(authDate) || authDate <= 0) {
|
||||
throw new Error('Telegram initData auth_date is invalid.');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now - authDate > TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS) {
|
||||
throw new Error('Telegram initData is expired.');
|
||||
}
|
||||
|
||||
const checkEntries = [...params.entries()]
|
||||
.filter(([key]) => key !== 'hash' && key !== 'signature')
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([key, value]) => `${key}=${value}`);
|
||||
const dataCheckString = checkEntries.join('\n');
|
||||
|
||||
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireTelegramBotToken()).digest();
|
||||
const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
|
||||
|
||||
if (!timingSafeEqualHex(expectedHash, receivedHash)) {
|
||||
throw new Error('Telegram initData signature is invalid.');
|
||||
}
|
||||
|
||||
const user = parseTelegramUser(params.get('user'));
|
||||
if (!user?.id) {
|
||||
throw new Error('Telegram user is missing in initData.');
|
||||
}
|
||||
|
||||
return {
|
||||
authDate: new Date(authDate * 1000),
|
||||
queryId: String(params.get('query_id') || '').trim() || null,
|
||||
startParam: String(params.get('start_param') || '').trim() || null,
|
||||
user,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user