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:
37
prisma/migrations/3_add_telegram_bot_sessions/migration.sql
Normal file
37
prisma/migrations/3_add_telegram_bot_sessions/migration.sql
Normal 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
3274
src/generated/prisma/index.d.ts
vendored
3274
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-359590a92aedb32557346cd24ba7dd77e7dd4a806bf36f6641715b1b22de87e3",
|
||||
"name": "prisma-client-37c1b83274036b18947b6108de6a4d4254d7d73ddef2fb10ea1aa825e682d461",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user