Add Telegram bot login sessions
All checks were successful
Build and deploy Backend / build (push) Successful in 49s

This commit is contained in:
Ruslan Bakiev
2026-05-08 19:31:40 +07:00
parent a0627f6f2c
commit 71561724a5
13 changed files with 3683 additions and 22 deletions

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "UserSession" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TelegramLoginRequest" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"sessionToken" TEXT,
"userId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TelegramLoginRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "TelegramLoginRequest_tokenHash_key" ON "TelegramLoginRequest"("tokenHash");
-- AddForeignKey
ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TelegramLoginRequest" ADD CONSTRAINT "TelegramLoginRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -35,11 +35,34 @@ model User {
lastName String?
photoUrl String?
languageCode String?
sessions UserSession[]
loginRequests TelegramLoginRequest[]
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserSession {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
}
model TelegramLoginRequest {
id String @id @default(cuid())
tokenHash String @unique
status String @default("PENDING")
sessionToken String?
userId String?
user User? @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VoiceExperience {
id String @id @default(cuid())
placeId String

View 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);
}

View File

@@ -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));
}

View File

@@ -10,7 +10,12 @@ export const config = {
databaseUrl: process.env.DATABASE_URL ?? '',
hatchetToken: process.env.HATCHET_CLIENT_TOKEN ?? '',
telegramMiniAppBotToken: process.env.TELEGRAM_MINI_APP_BOT_TOKEN ?? '',
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot',
telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn',
telegramAuthMaxAgeSeconds: Number(
process.env.TELEGRAM_AUTH_MAX_AGE_SECONDS ?? '86400',
),
sessionMaxAgeSeconds: Number(process.env.SESSION_MAX_AGE_SECONDS ?? '2592000'),
botLoginMaxAgeSeconds: Number(process.env.BOT_LOGIN_MAX_AGE_SECONDS ?? '300'),
};

File diff suppressed because one or more lines are too long

View File

@@ -142,6 +142,25 @@ exports.Prisma.UserScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserSessionScalarFieldEnum = {
id: 'id',
tokenHash: 'tokenHash',
userId: 'userId',
expiresAt: 'expiresAt',
createdAt: 'createdAt'
};
exports.Prisma.TelegramLoginRequestScalarFieldEnum = {
id: 'id',
tokenHash: 'tokenHash',
status: 'status',
sessionToken: 'sessionToken',
userId: 'userId',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.VoiceExperienceScalarFieldEnum = {
id: 'id',
placeId: 'placeId',
@@ -192,6 +211,8 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
exports.Prisma.ModelName = {
Place: 'Place',
User: 'User',
UserSession: 'UserSession',
TelegramLoginRequest: 'TelegramLoginRequest',
VoiceExperience: 'VoiceExperience'
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-359590a92aedb32557346cd24ba7dd77e7dd4a806bf36f6641715b1b22de87e3",
"name": "prisma-client-37c1b83274036b18947b6108de6a4d4254d7d73ddef2fb10ea1aa825e682d461",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View File

@@ -28,16 +28,39 @@ model Place {
}
model User {
id String @id @default(cuid())
telegramId String @unique
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
sessions UserSession[]
loginRequests TelegramLoginRequest[]
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserSession {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
}
model TelegramLoginRequest {
id String @id @default(cuid())
tokenHash String @unique
status String @default("PENDING")
sessionToken String?
userId String?
user User? @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VoiceExperience {

View File

@@ -5,12 +5,17 @@ import {
requireTelegramUser,
type TelegramLoginData,
} from '../auth/telegram.js';
import {
createTelegramBotLogin,
getTelegramBotLoginStatus,
} from '../auth/telegram-bot-login.js';
import { listPlaces, listVoiceExperiences } from './places.js';
import { createVoiceExperience } from './voice-experiences.js';
export type GraphqlContext = {
telegramInitData?: string;
telegramLoginData?: string;
mapflowSessionToken?: string;
};
export const schema = /* GraphQL */ `
@@ -83,13 +88,28 @@ export const schema = /* GraphQL */ `
user: User!
}
type TelegramBotLoginPayload {
token: String!
botUrl: String!
expiresAt: String!
}
type TelegramBotLoginStatus {
status: String!
sessionToken: String
user: User
}
type Query {
health: String!
me: User!
telegramBotLoginStatus(token: String!): TelegramBotLoginStatus!
places: [Place!]!
voiceExperiences: [VoiceExperience!]!
}
type Mutation {
startTelegramBotLogin: TelegramBotLoginPayload!
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
@@ -100,6 +120,12 @@ export const resolvers = {
JSON: GraphQLJSONObject,
Query: {
health: () => 'ok',
me: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
return requireTelegramUser(graphqlContext);
},
telegramBotLoginStatus: async (_: unknown, args: { token: string }) =>
getTelegramBotLoginStatus(args.token),
places: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext);
@@ -112,6 +138,7 @@ export const resolvers = {
},
},
Mutation: {
startTelegramBotLogin: async () => createTelegramBotLogin(),
authenticateTelegram: async (
_: unknown,
args: { input: { initData: string } },

View File

@@ -4,6 +4,7 @@ import mercurius from 'mercurius';
import { config } from './config.js';
import { prisma } from './prisma.js';
import { resolvers, schema } from './graphql/schema.js';
import { handleTelegramBotWebhook } from './auth/telegram-bot-login.js';
const app = Fastify({ logger: true });
@@ -14,15 +15,24 @@ app.register(mercurius, {
context: async (request) => {
const initDataHeader = request.headers['x-telegram-init-data'];
const loginDataHeader = request.headers['x-telegram-login-data'];
const sessionTokenHeader = request.headers['x-mapflow-session-token'];
return {
telegramInitData: Array.isArray(initDataHeader) ? initDataHeader[0] : initDataHeader,
telegramLoginData: Array.isArray(loginDataHeader) ? loginDataHeader[0] : loginDataHeader,
mapflowSessionToken: Array.isArray(sessionTokenHeader) ? sessionTokenHeader[0] : sessionTokenHeader,
};
},
});
app.get('/health', async () => ({ ok: true }));
app.post('/telegram/webhook', async (request, reply) => {
const secretHeader = request.headers['x-telegram-bot-api-secret-token'];
const secretToken = Array.isArray(secretHeader) ? secretHeader[0] : secretHeader;
await handleTelegramBotWebhook(request.body as never, secretToken);
return reply.send({ ok: true });
});
app.addHook('onClose', async () => {
await prisma.$disconnect();
});