Add Telegram bot login sessions
All checks were successful
Build and deploy Backend / build (push) Successful in 49s
All checks were successful
Build and deploy Backend / build (push) Successful in 49s
This commit is contained in:
189
src/auth/telegram-bot-login.ts
Normal file
189
src/auth/telegram-bot-login.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { prisma } from '../prisma.js';
|
||||
import {
|
||||
sha256Hex,
|
||||
upsertTelegramUser,
|
||||
type TelegramUserPayload,
|
||||
} from './telegram.js';
|
||||
|
||||
type TelegramUpdate = {
|
||||
message?: {
|
||||
chat: { id: number };
|
||||
text?: string;
|
||||
from?: TelegramBotUser;
|
||||
};
|
||||
};
|
||||
|
||||
type TelegramBotUser = {
|
||||
id: number;
|
||||
is_bot?: boolean;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
};
|
||||
|
||||
const loginPrefix = 'login_';
|
||||
|
||||
function randomToken() {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function expiresIn(seconds: number) {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
|
||||
function botApiUrl(method: string) {
|
||||
if (!config.telegramMiniAppBotToken) {
|
||||
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
|
||||
}
|
||||
|
||||
return `https://api.telegram.org/bot${config.telegramMiniAppBotToken}/${method}`;
|
||||
}
|
||||
|
||||
async function callTelegram(method: string, body: Record<string, unknown>) {
|
||||
const response = await fetch(botApiUrl(method), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Telegram ${method} failed with ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { ok: boolean; description?: string };
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.description ?? `Telegram ${method} failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
function userPayload(from: TelegramBotUser): TelegramUserPayload {
|
||||
return {
|
||||
id: from.id,
|
||||
username: from.username,
|
||||
firstName: from.first_name,
|
||||
lastName: from.last_name,
|
||||
languageCode: from.language_code,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendLoginMessage(chatId: number, text: string, token?: string) {
|
||||
const replyMarkup = token
|
||||
? {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Вернуться в MapFlow',
|
||||
url: `${config.webAppUrl}?telegram_login=${encodeURIComponent(token)}`,
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await callTelegram('sendMessage', {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
reply_markup: replyMarkup,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTelegramBotLogin() {
|
||||
const token = randomToken();
|
||||
const expiresAt = expiresIn(config.botLoginMaxAgeSeconds);
|
||||
|
||||
await prisma.telegramLoginRequest.create({
|
||||
data: {
|
||||
tokenHash: sha256Hex(token),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
botUrl: `https://t.me/${config.telegramBotUsername}?start=${loginPrefix}${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTelegramBotLoginStatus(token: string) {
|
||||
const request = await prisma.telegramLoginRequest.findUnique({
|
||||
where: { tokenHash: sha256Hex(token) },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return { status: 'EXPIRED', sessionToken: null, user: null };
|
||||
}
|
||||
|
||||
if (request.status === 'PENDING' && request.expiresAt <= new Date()) {
|
||||
await prisma.telegramLoginRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
return { status: 'EXPIRED', sessionToken: null, user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
status: request.status,
|
||||
sessionToken: request.sessionToken,
|
||||
user: request.user,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTelegramBotWebhook(
|
||||
update: TelegramUpdate,
|
||||
secretToken?: string,
|
||||
) {
|
||||
if (config.telegramWebhookSecret && secretToken !== config.telegramWebhookSecret) {
|
||||
throw new Error('Telegram webhook secret is invalid.');
|
||||
}
|
||||
|
||||
const message = update.message;
|
||||
const text = message?.text;
|
||||
const from = message?.from;
|
||||
const chatId = message?.chat.id;
|
||||
if (!message || !text || !from || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [command, payload] = text.split(' ');
|
||||
if (command !== '/start' || !payload?.startsWith(loginPrefix)) {
|
||||
await sendLoginMessage(chatId, 'Открой вход с сайта MapFlow.');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = payload.slice(loginPrefix.length);
|
||||
const request = await prisma.telegramLoginRequest.findUnique({
|
||||
where: { tokenHash: sha256Hex(token) },
|
||||
});
|
||||
|
||||
if (!request || request.status !== 'PENDING' || request.expiresAt <= new Date()) {
|
||||
await sendLoginMessage(chatId, 'Ссылка входа устарела. Открой вход заново.');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await upsertTelegramUser(userPayload(from));
|
||||
const sessionToken = randomToken();
|
||||
await prisma.userSession.create({
|
||||
data: {
|
||||
tokenHash: sha256Hex(sessionToken),
|
||||
userId: user.id,
|
||||
expiresAt: expiresIn(config.sessionMaxAgeSeconds),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.telegramLoginRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: 'CONFIRMED',
|
||||
sessionToken,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await sendLoginMessage(chatId, 'Готово. Можно вернуться в MapFlow.', token);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export type TelegramLoginData = {
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type TelegramUserPayload = {
|
||||
export type TelegramUserPayload = {
|
||||
id: number;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
@@ -34,16 +34,21 @@ type TelegramUserPayload = {
|
||||
type TelegramAuthData = {
|
||||
telegramInitData?: string;
|
||||
telegramLoginData?: string;
|
||||
mapflowSessionToken?: string;
|
||||
};
|
||||
|
||||
function hmacSha256(key: string | Buffer, value: string) {
|
||||
return createHmac('sha256', key).update(value).digest();
|
||||
}
|
||||
|
||||
function sha256(value: string) {
|
||||
export function sha256(value: string) {
|
||||
return createHash('sha256').update(value).digest();
|
||||
}
|
||||
|
||||
export function sha256Hex(value: string) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function assertValidHash(receivedHash: string, expectedHash: Buffer, dataType: string) {
|
||||
const received = Buffer.from(receivedHash, 'hex');
|
||||
if (received.length !== expectedHash.length) {
|
||||
@@ -134,7 +139,7 @@ function parseTelegramLoginJson(loginData: string) {
|
||||
return parseTelegramLoginData(JSON.parse(loginData) as TelegramLoginData);
|
||||
}
|
||||
|
||||
async function upsertTelegramUser(user: TelegramUserPayload) {
|
||||
export async function upsertTelegramUser(user: TelegramUserPayload) {
|
||||
return prisma.user.upsert({
|
||||
where: { telegramId: String(user.id) },
|
||||
create: {
|
||||
@@ -163,7 +168,24 @@ export async function getOrCreateTelegramLoginUser(loginData: TelegramLoginData)
|
||||
return upsertTelegramUser(parseTelegramLoginData(loginData));
|
||||
}
|
||||
|
||||
export async function getUserBySessionToken(sessionToken: string) {
|
||||
const session = await prisma.userSession.findUnique({
|
||||
where: { tokenHash: sha256Hex(sessionToken) },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
throw new Error('Telegram authorization is required.');
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export async function requireTelegramUser(authData: TelegramAuthData) {
|
||||
if (authData.mapflowSessionToken) {
|
||||
return getUserBySessionToken(authData.mapflowSessionToken);
|
||||
}
|
||||
|
||||
if (authData.telegramInitData) {
|
||||
return upsertTelegramUser(parseTelegramInitData(authData.telegramInitData));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user