Support Telegram login widget auth
Some checks failed
Build and deploy Backend / build (push) Has been cancelled
Some checks failed
Build and deploy Backend / build (push) Has been cancelled
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { prisma } from '../prisma.js';
|
import { prisma } from '../prisma.js';
|
||||||
@@ -12,41 +12,75 @@ type TelegramInitDataUser = {
|
|||||||
language_code?: string;
|
language_code?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TelegramLoginData = {
|
||||||
|
id: number;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
username?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
auth_date: number;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TelegramUserPayload = {
|
||||||
|
id: number;
|
||||||
|
username?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
languageCode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TelegramAuthData = {
|
||||||
|
telegramInitData?: string;
|
||||||
|
telegramLoginData?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function hmacSha256(key: string | Buffer, value: string) {
|
function hmacSha256(key: string | Buffer, value: string) {
|
||||||
return createHmac('sha256', key).update(value).digest();
|
return createHmac('sha256', key).update(value).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertValidHash(receivedHash: string, expectedHash: Buffer) {
|
function sha256(value: string) {
|
||||||
|
return createHash('sha256').update(value).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidHash(receivedHash: string, expectedHash: Buffer, dataType: string) {
|
||||||
const received = Buffer.from(receivedHash, 'hex');
|
const received = Buffer.from(receivedHash, 'hex');
|
||||||
if (received.length !== expectedHash.length) {
|
if (received.length !== expectedHash.length) {
|
||||||
throw new Error('Telegram init data hash is invalid.');
|
throw new Error(`Telegram ${dataType} hash is invalid.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!timingSafeEqual(received, expectedHash)) {
|
if (!timingSafeEqual(received, expectedHash)) {
|
||||||
throw new Error('Telegram init data hash is invalid.');
|
throw new Error(`Telegram ${dataType} hash is invalid.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTelegramInitData(initData: string) {
|
function assertConfiguredBotToken() {
|
||||||
if (!config.telegramMiniAppBotToken) {
|
if (!config.telegramMiniAppBotToken) {
|
||||||
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
|
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFreshAuthDate(authDate: number, dataType: string) {
|
||||||
|
if (!Number.isFinite(authDate)) {
|
||||||
|
throw new Error(`Telegram ${dataType} auth_date is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
|
||||||
|
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
|
||||||
|
throw new Error(`Telegram ${dataType} is expired.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTelegramInitData(initData: string): TelegramUserPayload {
|
||||||
|
assertConfiguredBotToken();
|
||||||
const params = new URLSearchParams(initData);
|
const params = new URLSearchParams(initData);
|
||||||
const receivedHash = params.get('hash');
|
const receivedHash = params.get('hash');
|
||||||
if (!receivedHash) {
|
if (!receivedHash) {
|
||||||
throw new Error('Telegram init data hash is required.');
|
throw new Error('Telegram init data hash is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authDate = Number(params.get('auth_date'));
|
assertFreshAuthDate(Number(params.get('auth_date')), 'init data');
|
||||||
if (!Number.isFinite(authDate)) {
|
|
||||||
throw new Error('Telegram auth_date is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
|
|
||||||
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
|
|
||||||
throw new Error('Telegram init data is expired.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataCheckString = [...params.entries()]
|
const dataCheckString = [...params.entries()]
|
||||||
.filter(([key]) => key !== 'hash')
|
.filter(([key]) => key !== 'hash')
|
||||||
@@ -56,43 +90,87 @@ function parseTelegramInitData(initData: string) {
|
|||||||
|
|
||||||
const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken);
|
const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken);
|
||||||
const expectedHash = hmacSha256(secretKey, dataCheckString);
|
const expectedHash = hmacSha256(secretKey, dataCheckString);
|
||||||
assertValidHash(receivedHash, expectedHash);
|
assertValidHash(receivedHash, expectedHash, 'init data');
|
||||||
|
|
||||||
const rawUser = params.get('user');
|
const rawUser = params.get('user');
|
||||||
if (!rawUser) {
|
if (!rawUser) {
|
||||||
throw new Error('Telegram user is required.');
|
throw new Error('Telegram user is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.parse(rawUser) as TelegramInitDataUser;
|
const user = JSON.parse(rawUser) as TelegramInitDataUser;
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
photoUrl: user.photo_url,
|
||||||
|
languageCode: user.language_code,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateTelegramUser(initData: string) {
|
function parseTelegramLoginData(loginData: TelegramLoginData): TelegramUserPayload {
|
||||||
const user = parseTelegramInitData(initData);
|
assertConfiguredBotToken();
|
||||||
|
assertFreshAuthDate(Number(loginData.auth_date), 'login data');
|
||||||
|
|
||||||
|
const dataCheckString = Object.entries(loginData)
|
||||||
|
.filter(([key, value]) => key !== 'hash' && value !== undefined && value !== null)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const expectedHash = hmacSha256(sha256(config.telegramMiniAppBotToken), dataCheckString);
|
||||||
|
assertValidHash(loginData.hash, expectedHash, 'login data');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(loginData.id),
|
||||||
|
username: loginData.username,
|
||||||
|
firstName: loginData.first_name,
|
||||||
|
lastName: loginData.last_name,
|
||||||
|
photoUrl: loginData.photo_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTelegramLoginJson(loginData: string) {
|
||||||
|
return parseTelegramLoginData(JSON.parse(loginData) as TelegramLoginData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTelegramUser(user: TelegramUserPayload) {
|
||||||
return prisma.user.upsert({
|
return prisma.user.upsert({
|
||||||
where: { telegramId: String(user.id) },
|
where: { telegramId: String(user.id) },
|
||||||
create: {
|
create: {
|
||||||
telegramId: String(user.id),
|
telegramId: String(user.id),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
firstName: user.first_name,
|
firstName: user.firstName,
|
||||||
lastName: user.last_name,
|
lastName: user.lastName,
|
||||||
photoUrl: user.photo_url,
|
photoUrl: user.photoUrl,
|
||||||
languageCode: user.language_code,
|
languageCode: user.languageCode,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
firstName: user.first_name,
|
firstName: user.firstName,
|
||||||
lastName: user.last_name,
|
lastName: user.lastName,
|
||||||
photoUrl: user.photo_url,
|
photoUrl: user.photoUrl,
|
||||||
languageCode: user.language_code,
|
languageCode: user.languageCode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireTelegramUser(initData?: string) {
|
export async function getOrCreateTelegramUser(initData: string) {
|
||||||
if (!initData) {
|
return upsertTelegramUser(parseTelegramInitData(initData));
|
||||||
throw new Error('Telegram authorization is required.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return getOrCreateTelegramUser(initData);
|
export async function getOrCreateTelegramLoginUser(loginData: TelegramLoginData) {
|
||||||
|
return upsertTelegramUser(parseTelegramLoginData(loginData));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireTelegramUser(authData: TelegramAuthData) {
|
||||||
|
if (authData.telegramInitData) {
|
||||||
|
return upsertTelegramUser(parseTelegramInitData(authData.telegramInitData));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authData.telegramLoginData) {
|
||||||
|
return upsertTelegramUser(parseTelegramLoginJson(authData.telegramLoginData));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Telegram authorization is required.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { GraphQLJSONObject } from './scalars.js';
|
import { GraphQLJSONObject } from './scalars.js';
|
||||||
import { getOrCreateTelegramUser, requireTelegramUser } from '../auth/telegram.js';
|
import {
|
||||||
|
getOrCreateTelegramLoginUser,
|
||||||
|
getOrCreateTelegramUser,
|
||||||
|
requireTelegramUser,
|
||||||
|
type TelegramLoginData,
|
||||||
|
} from '../auth/telegram.js';
|
||||||
import { listPlaces, listVoiceExperiences } from './places.js';
|
import { listPlaces, listVoiceExperiences } from './places.js';
|
||||||
import { createVoiceExperience } from './voice-experiences.js';
|
import { createVoiceExperience } from './voice-experiences.js';
|
||||||
|
|
||||||
export type GraphqlContext = {
|
export type GraphqlContext = {
|
||||||
telegramInitData?: string;
|
telegramInitData?: string;
|
||||||
|
telegramLoginData?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const schema = /* GraphQL */ `
|
export const schema = /* GraphQL */ `
|
||||||
@@ -63,6 +69,16 @@ export const schema = /* GraphQL */ `
|
|||||||
initData: String!
|
initData: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input AuthenticateTelegramLoginInput {
|
||||||
|
id: Float!
|
||||||
|
first_name: String
|
||||||
|
last_name: String
|
||||||
|
username: String
|
||||||
|
photo_url: String
|
||||||
|
auth_date: Float!
|
||||||
|
hash: String!
|
||||||
|
}
|
||||||
|
|
||||||
type AuthPayload {
|
type AuthPayload {
|
||||||
user: User!
|
user: User!
|
||||||
}
|
}
|
||||||
@@ -75,6 +91,7 @@ export const schema = /* GraphQL */ `
|
|||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
|
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
|
||||||
|
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
|
||||||
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
|
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -85,12 +102,12 @@ export const resolvers = {
|
|||||||
health: () => 'ok',
|
health: () => 'ok',
|
||||||
places: async (_: unknown, __: unknown, context: unknown) => {
|
places: async (_: unknown, __: unknown, context: unknown) => {
|
||||||
const graphqlContext = context as GraphqlContext;
|
const graphqlContext = context as GraphqlContext;
|
||||||
await requireTelegramUser(graphqlContext.telegramInitData);
|
await requireTelegramUser(graphqlContext);
|
||||||
return listPlaces();
|
return listPlaces();
|
||||||
},
|
},
|
||||||
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
|
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
|
||||||
const graphqlContext = context as GraphqlContext;
|
const graphqlContext = context as GraphqlContext;
|
||||||
await requireTelegramUser(graphqlContext.telegramInitData);
|
await requireTelegramUser(graphqlContext);
|
||||||
return listVoiceExperiences();
|
return listVoiceExperiences();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,13 +116,17 @@ export const resolvers = {
|
|||||||
_: unknown,
|
_: unknown,
|
||||||
args: { input: { initData: string } },
|
args: { input: { initData: string } },
|
||||||
) => ({ user: await getOrCreateTelegramUser(args.input.initData) }),
|
) => ({ user: await getOrCreateTelegramUser(args.input.initData) }),
|
||||||
|
authenticateTelegramLogin: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { input: TelegramLoginData },
|
||||||
|
) => ({ user: await getOrCreateTelegramLoginUser(args.input) }),
|
||||||
createVoiceExperience: async (
|
createVoiceExperience: async (
|
||||||
_: unknown,
|
_: unknown,
|
||||||
args: { input: Parameters<typeof createVoiceExperience>[0] },
|
args: { input: Parameters<typeof createVoiceExperience>[0] },
|
||||||
context: unknown,
|
context: unknown,
|
||||||
) => {
|
) => {
|
||||||
const graphqlContext = context as GraphqlContext;
|
const graphqlContext = context as GraphqlContext;
|
||||||
const user = await requireTelegramUser(graphqlContext.telegramInitData);
|
const user = await requireTelegramUser(graphqlContext);
|
||||||
return createVoiceExperience(args.input, user.id);
|
return createVoiceExperience(args.input, user.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ app.register(mercurius, {
|
|||||||
resolvers,
|
resolvers,
|
||||||
graphiql: true,
|
graphiql: true,
|
||||||
context: async (request) => {
|
context: async (request) => {
|
||||||
const header = request.headers['x-telegram-init-data'];
|
const initDataHeader = request.headers['x-telegram-init-data'];
|
||||||
|
const loginDataHeader = request.headers['x-telegram-login-data'];
|
||||||
return {
|
return {
|
||||||
telegramInitData: Array.isArray(header) ? header[0] : header,
|
telegramInitData: Array.isArray(initDataHeader) ? initDataHeader[0] : initDataHeader,
|
||||||
|
telegramLoginData: Array.isArray(loginDataHeader) ? loginDataHeader[0] : loginDataHeader,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user