From 0341b2eafe8bba28907398d2c9f69cd72ded7fa4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 18 Feb 2026 06:27:00 +0300 Subject: [PATCH] feat: add telegram verification --- prisma/schema.prisma | 2 + src/app.module.ts | 16 ++- src/config/env/index.ts | 1 + src/config/env/telegram.env.ts | 17 +++ .../interfaces/all-configs.interface.ts | 2 + src/config/interfaces/telegram.interface.ts | 6 + src/config/validators/index.ts | 1 + src/config/validators/telegram.validator.ts | 16 +++ src/modules/auth/auth.module.ts | 18 ++- src/modules/auth/auth.service.ts | 46 ++------ src/modules/telegram/telegram.controller.ts | 24 ++++ src/modules/telegram/telegram.module.ts | 13 +++ src/modules/telegram/telegram.repository.ts | 15 +++ src/modules/telegram/telegram.service.ts | 105 ++++++++++++++++++ src/modules/token/token.module.ts | 19 ++++ src/modules/token/token.service.ts | 46 ++++++++ yarn.lock | 6 +- 17 files changed, 299 insertions(+), 54 deletions(-) create mode 100644 src/config/env/telegram.env.ts create mode 100644 src/config/interfaces/telegram.interface.ts create mode 100644 src/config/validators/telegram.validator.ts create mode 100644 src/modules/telegram/telegram.controller.ts create mode 100644 src/modules/telegram/telegram.module.ts create mode 100644 src/modules/telegram/telegram.repository.ts create mode 100644 src/modules/telegram/telegram.service.ts create mode 100644 src/modules/token/token.module.ts create mode 100644 src/modules/token/token.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b65ebd..19d6a89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,8 @@ model Account { role Role @default(USER) + telegramId String? @unique @map("telegram_id") + createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app.module.ts b/src/app.module.ts index da65224..822145e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,24 +1,34 @@ import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' -import { dabataseEnv, grpcEnv, passportEnv, redisEnv } from './config' +import { + dabataseEnv, + grpcEnv, + passportEnv, + redisEnv, + telegramEnv +} from './config' import { PrismaModule } from './infra/prisma/prisma.module' import { RedisModule } from './infra/redis/redis.module' import { AccountModule } from './modules/account/account.module' import { AuthModule } from './modules/auth/auth.module' import { OtpModule } from './modules/otp/otp.module' +import { TelegramModule } from './modules/telegram/telegram.module' +import { TokenModule } from './modules/token/token.module' @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [dabataseEnv, grpcEnv, redisEnv, passportEnv] + load: [dabataseEnv, grpcEnv, redisEnv, passportEnv, telegramEnv] }), PrismaModule, RedisModule, AuthModule, OtpModule, - AccountModule + AccountModule, + TelegramModule, + TokenModule ] }) export class AppModule {} diff --git a/src/config/env/index.ts b/src/config/env/index.ts index 30ff333..b432198 100644 --- a/src/config/env/index.ts +++ b/src/config/env/index.ts @@ -2,3 +2,4 @@ export * from './grpc.env' export * from './database.env' export * from './redis.env' export * from './passport.env' +export * from './telegram.env' diff --git a/src/config/env/telegram.env.ts b/src/config/env/telegram.env.ts new file mode 100644 index 0000000..18ee9fc --- /dev/null +++ b/src/config/env/telegram.env.ts @@ -0,0 +1,17 @@ +import { registerAs } from '@nestjs/config' + +import { validateEnv } from '@/shared/utils' + +import { TelegramConfig } from '../interfaces/telegram.interface' +import { TelegramValidator } from '../validators' + +export const telegramEnv = registerAs('telegram', () => { + validateEnv(process.env, TelegramValidator) + + return { + telegramBotId: process.env.TELEGRAM_BOT_ID, + telegramBotToken: process.env.TELEGRAM_BOT_TOKEN, + telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME, + telegramRedirectOrigin: process.env.TELEGRAM_REDIRECT_ORIGIN + } +}) diff --git a/src/config/interfaces/all-configs.interface.ts b/src/config/interfaces/all-configs.interface.ts index 9a39d8f..482afa0 100644 --- a/src/config/interfaces/all-configs.interface.ts +++ b/src/config/interfaces/all-configs.interface.ts @@ -2,10 +2,12 @@ import { DatabaseConfig } from './database.interface' import { GrpcConfig } from './grpc.interface' import { PassportConfig } from './passport.interface' import { RedisConfig } from './redis.interface' +import { TelegramConfig } from './telegram.interface' export interface AllConfigs { database: DatabaseConfig grpc: GrpcConfig redis: RedisConfig passport: PassportConfig + telegram: TelegramConfig } diff --git a/src/config/interfaces/telegram.interface.ts b/src/config/interfaces/telegram.interface.ts new file mode 100644 index 0000000..a475daf --- /dev/null +++ b/src/config/interfaces/telegram.interface.ts @@ -0,0 +1,6 @@ +export interface TelegramConfig { + telegramBotId: string + telegramBotToken: string + telegramBotUsername: string + telegramRedirectOrigin: string +} diff --git a/src/config/validators/index.ts b/src/config/validators/index.ts index 283f136..8edc784 100644 --- a/src/config/validators/index.ts +++ b/src/config/validators/index.ts @@ -2,3 +2,4 @@ export * from './grpc.validator' export * from './database.validator' export * from './redis.validator' export * from './passport.validator' +export * from './telegram.validator' diff --git a/src/config/validators/telegram.validator.ts b/src/config/validators/telegram.validator.ts new file mode 100644 index 0000000..9afcd92 --- /dev/null +++ b/src/config/validators/telegram.validator.ts @@ -0,0 +1,16 @@ +import { IsString, IsUrl } from 'class-validator' + +export class TelegramValidator { + @IsString() + public TELEGRAM_BOT_ID: string + + @IsString() + public TELEGRAM_BOT_TOKEN: string + + @IsString() + public TELEGRAM_BOT_USERNAME: string + + @IsString() + @IsUrl() + public TELEGRAM_REDIRECT_ORIGIN: string +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1b19603..71ca3c0 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,9 +1,7 @@ import { Module } from '@nestjs/common' -import { ConfigService } from '@nestjs/config' -import { PassportModule } from '@teacinema/passport' -import { getPassportConfig } from '@/config' import { OtpService } from '@/modules/otp/otp.service' +import { TokenService } from '@/modules/token/token.service' import { UserRepository } from '@/shared/utils/repositories' import { AuthController } from './auth.controller' @@ -11,13 +9,13 @@ import { AuthRepository } from './auth.repository' import { AuthService } from './auth.service' @Module({ - imports: [ - PassportModule.registerAsync({ - useFactory: getPassportConfig, - inject: [ConfigService] - }) - ], controllers: [AuthController], - providers: [AuthService, AuthRepository, UserRepository, OtpService] + providers: [ + AuthService, + AuthRepository, + UserRepository, + OtpService, + TokenService + ] }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 60e81b8..a07addb 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common' -import { ConfigService } from '@nestjs/config' import { RpcException } from '@nestjs/microservices' import { Account } from '@prisma/generated/client' import { RpcStatus } from '@teacinema/common' @@ -11,33 +10,22 @@ import { VerifyOtpRequest, VerifyOtpResponse } from '@teacinema/contracts/gen/auth' -import { PassportService } from '@teacinema/passport' -import { AllConfigs } from '@/config' import { OtpService } from '@/modules/otp/otp.service' import { UserRepository } from '@/shared/utils/repositories' +import { TokenService } from '../token/token.service' + import { AuthRepository } from './auth.repository' @Injectable() export class AuthService { - private readonly ACCESS_TOKEN_TTL: number - private readonly REFRESH_TOKEN_TTL: number - public constructor( - private readonly configService: ConfigService, private readonly authRepository: AuthRepository, private readonly userRepository: UserRepository, private readonly otpService: OtpService, - private readonly passportService: PassportService - ) { - this.ACCESS_TOKEN_TTL = configService.get('passport.accessTtl', { - infer: true - }) - this.REFRESH_TOKEN_TTL = configService.get('passport.refreshTtl', { - infer: true - }) - } + private readonly tokenService: TokenService + ) {} public async sendOtp(data: SendOtpRequest): Promise { const { identifier, type } = data @@ -99,39 +87,21 @@ export class AuthService { }) } - return this.generateTokens(account.id) - } - - private generateTokens(userId: Account['id']) { - const payload = { - sub: userId, - iat: Date.now(), - exp: Date.now() + this.ACCESS_TOKEN_TTL - } - const accessToken = this.passportService.generate( - payload.sub, - this.ACCESS_TOKEN_TTL - ) - const refreshToken = this.passportService.generate( - payload.sub, - this.REFRESH_TOKEN_TTL - ) - - return { accessToken, refreshToken } + return this.tokenService.generateTokens(account.id) } public refresh(data: RefreshRequest): RefreshResponse { const { refreshToken } = data - const result = this.passportService.verify(refreshToken) + const result = this.tokenService.verify(refreshToken) if (!result.valid) { throw new RpcException({ code: RpcStatus.UNAUTHENTICATED, - // @ts-ignore + // @ts-expect-error -- TODO: fixme details: result.reason }) } - return this.generateTokens(result.userId) + return this.tokenService.generateTokens(result.userId) } } diff --git a/src/modules/telegram/telegram.controller.ts b/src/modules/telegram/telegram.controller.ts new file mode 100644 index 0000000..13cd91e --- /dev/null +++ b/src/modules/telegram/telegram.controller.ts @@ -0,0 +1,24 @@ +import { Controller } from '@nestjs/common' +import { GrpcMethod } from '@nestjs/microservices' +import type { + TelegramInitResponse, + TelegramVerifyRequest, + TelegramVerifyResponse +} from '@teacinema/contracts/gen/auth' + +import { TelegramService } from './telegram.service' + +@Controller() +export class TelegramController { + constructor(private readonly telegramService: TelegramService) {} + + @GrpcMethod('AuthService', 'TelegramInit') + public getAuthUrl(): TelegramInitResponse { + return this.telegramService.getTelegramUrl() + } + + @GrpcMethod('AuthService', 'TelegramVerify') + public verify(data: TelegramVerifyRequest): Promise { + return this.telegramService.verify(data) + } +} diff --git a/src/modules/telegram/telegram.module.ts b/src/modules/telegram/telegram.module.ts new file mode 100644 index 0000000..09e9a3e --- /dev/null +++ b/src/modules/telegram/telegram.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' + +import { TelegramRepository } from '@/modules/telegram/telegram.repository' +import { TokenService } from '@/modules/token/token.service' + +import { TelegramController } from './telegram.controller' +import { TelegramService } from './telegram.service' + +@Module({ + controllers: [TelegramController], + providers: [TelegramService, TelegramRepository, TokenService] +}) +export class TelegramModule {} diff --git a/src/modules/telegram/telegram.repository.ts b/src/modules/telegram/telegram.repository.ts new file mode 100644 index 0000000..d21cbd6 --- /dev/null +++ b/src/modules/telegram/telegram.repository.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common' +import { Account } from '@prisma/generated/client' + +import { PrismaService } from '@/infra/prisma/prisma.service' + +@Injectable() +export class TelegramRepository { + constructor(private readonly prismaService: PrismaService) {} + + public findByTelegramId(id: Account['telegramId']) { + return this.prismaService.account.findUnique({ + where: { telegramId: id } + }) + } +} diff --git a/src/modules/telegram/telegram.service.ts b/src/modules/telegram/telegram.service.ts new file mode 100644 index 0000000..2cd28a4 --- /dev/null +++ b/src/modules/telegram/telegram.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { RpcException } from '@nestjs/microservices' +import { RpcStatus } from '@teacinema/common' +import { + TelegramInitResponse, + TelegramVerifyRequest +} from '@teacinema/contracts/gen/auth' +import { createHash, createHmac, randomBytes } from 'node:crypto' + +import { AllConfigs } from '@/config' +import { RedisService } from '@/infra/redis/redis.service' +import { TokenService } from '@/modules/token/token.service' + +import { TelegramRepository } from './telegram.repository' + +@Injectable() +export class TelegramService { + private readonly BOT_ID: string + private readonly BOT_TOKEN: string + private readonly BOT_USERNAME: string + private readonly REDIRECT_ORIGIN: string + + public constructor( + private readonly redisService: RedisService, + private readonly configService: ConfigService, + private readonly telegramRepository: TelegramRepository, + private readonly tokenService: TokenService + ) { + this.BOT_ID = configService.get('telegram.telegramBotId', { infer: true }) + this.BOT_TOKEN = configService.get('telegram.telegramBotToken', { + infer: true + }) + this.BOT_USERNAME = configService.get('telegram.telegramBotUsername', { + infer: true + }) + this.REDIRECT_ORIGIN = configService.get( + 'telegram.telegramRedirectOrigin', + { infer: true } + ) + } + + public getTelegramUrl(): TelegramInitResponse { + const url = new URL('https://oauth.telegram.org/auth') + url.searchParams.append('bot_id', this.BOT_ID) + url.searchParams.append('origin', this.REDIRECT_ORIGIN) + url.searchParams.append('request_access', 'write') + url.searchParams.append('return_to', `${this.REDIRECT_ORIGIN}`) + + return { url: url.href } + } + + public async verify(data: TelegramVerifyRequest) { + const isValid = this.checkTelegramAuth(data.query) + + if (!isValid) { + throw new RpcException({ + code: RpcStatus.UNAUTHENTICATED, + message: 'Invalid telegram login response' + }) + } + + const telegramId = data.query.id + const exists = await this.telegramRepository.findByTelegramId(telegramId) + + if (exists && exists.phone) { + return this.tokenService.generateTokens(exists.id) + } + + const sessionId = randomBytes(16).toString('hex') + + await this.redisService.set( + `telegram_session:${sessionId}`, + JSON.stringify({ telegramId, username: data.query.username }), + 'EX', + 300 + ) + + return { + url: `https://t.me/${this.BOT_USERNAME}?start=${sessionId}` + } + } + + private checkTelegramAuth(query: Record) { + const hash = query.hash + + if (!hash) return false + + const dataCheckArr = Object.keys(query) + .filter(k => k !== 'hash') + .sort() + .map(k => `${k}=${query[k]}`) + + const dataCheckString = dataCheckArr.join('\n') + const secretKey = createHash('sha256') + .update(`${this.BOT_ID}:${this.BOT_TOKEN}`) + .digest('hex') + + const hmac = createHmac('sha256', secretKey) + .update(dataCheckString) + .digest('hex') + + return hmac === hash + } +} diff --git a/src/modules/token/token.module.ts b/src/modules/token/token.module.ts new file mode 100644 index 0000000..1096488 --- /dev/null +++ b/src/modules/token/token.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { PassportModule } from '@teacinema/passport' + +import { getPassportConfig } from '@/config' + +import { TokenService } from './token.service' + +@Module({ + imports: [ + PassportModule.registerAsync({ + useFactory: getPassportConfig, + inject: [ConfigService] + }) + ], + providers: [TokenService], + exports: [TokenService] +}) +export class TokenModule {} diff --git a/src/modules/token/token.service.ts b/src/modules/token/token.service.ts new file mode 100644 index 0000000..2fff3cb --- /dev/null +++ b/src/modules/token/token.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Account } from '@prisma/generated/client' +import { PassportService } from '@teacinema/passport' + +import { AllConfigs } from '@/config' + +@Injectable() +export class TokenService { + private readonly ACCESS_TOKEN_TTL: number + private readonly REFRESH_TOKEN_TTL: number + + constructor( + private readonly configService: ConfigService, + private readonly passportService: PassportService + ) { + this.ACCESS_TOKEN_TTL = configService.get('passport.accessTtl', { + infer: true + }) + this.REFRESH_TOKEN_TTL = configService.get('passport.refreshTtl', { + infer: true + }) + } + + public generateTokens(userId: Account['id']) { + const payload = { + sub: userId, + iat: Date.now(), + exp: Date.now() + this.ACCESS_TOKEN_TTL + } + const accessToken = this.passportService.generate( + payload.sub, + this.ACCESS_TOKEN_TTL + ) + const refreshToken = this.passportService.generate( + payload.sub, + this.REFRESH_TOKEN_TTL + ) + + return { accessToken, refreshToken } + } + + public verify(token: string) { + return this.passportService.verify(token) + } +} diff --git a/yarn.lock b/yarn.lock index 1caa01b..e970d2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1383,9 +1383,9 @@ integrity sha512-q3DURJbSk3k8MNWFIYaSM4LEcBgPbWa+HJmBz/nzYT4kuYitJVSXxpZ97kr0Ea+81AZwAU3JhQKlkK0SBWUi0A== "@teacinema/contracts@^1.0.0": - version "1.0.7" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.7/contracts-1.0.7.tgz#2c75a3118e4126d30f5c8193c4518bac3f1812ff" - integrity sha512-lE14PO/yBphYCu0BGFGyGAyvJ74v+ACz34MXA3fFo/PwThPVpudBReSG6oRyUACRybkN882GKXFU6LrJXvGbrQ== + version "1.0.8" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.8/contracts-1.0.8.tgz#4e31c3a370b0808629d9c93900c5f27958ef82e6" + integrity sha512-xyPyifbVwWelgo4MsvhhRl//To+b0Tnp8DtANCzwLBUhwPj3ZG19QgfPB7Ih7PsBU5TD6F80aNlITDxJbFgkFQ== dependencies: "@nestjs/microservices" "^11.1.12" protoc "33.4.0"