feat: add telegram verification

This commit is contained in:
Sergey Krylov 2026-02-18 06:27:00 +03:00
parent 6cb3d0d6f3
commit 0341b2eafe
17 changed files with 299 additions and 54 deletions

View File

@ -21,6 +21,8 @@ model Account {
role Role @default(USER) role Role @default(USER)
telegramId String? @unique @map("telegram_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -1,24 +1,34 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config' 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 { PrismaModule } from './infra/prisma/prisma.module'
import { RedisModule } from './infra/redis/redis.module' import { RedisModule } from './infra/redis/redis.module'
import { AccountModule } from './modules/account/account.module' import { AccountModule } from './modules/account/account.module'
import { AuthModule } from './modules/auth/auth.module' import { AuthModule } from './modules/auth/auth.module'
import { OtpModule } from './modules/otp/otp.module' import { OtpModule } from './modules/otp/otp.module'
import { TelegramModule } from './modules/telegram/telegram.module'
import { TokenModule } from './modules/token/token.module'
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [dabataseEnv, grpcEnv, redisEnv, passportEnv] load: [dabataseEnv, grpcEnv, redisEnv, passportEnv, telegramEnv]
}), }),
PrismaModule, PrismaModule,
RedisModule, RedisModule,
AuthModule, AuthModule,
OtpModule, OtpModule,
AccountModule AccountModule,
TelegramModule,
TokenModule
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@ -2,3 +2,4 @@ export * from './grpc.env'
export * from './database.env' export * from './database.env'
export * from './redis.env' export * from './redis.env'
export * from './passport.env' export * from './passport.env'
export * from './telegram.env'

17
src/config/env/telegram.env.ts vendored Normal file
View File

@ -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<TelegramConfig>('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
}
})

View File

@ -2,10 +2,12 @@ import { DatabaseConfig } from './database.interface'
import { GrpcConfig } from './grpc.interface' import { GrpcConfig } from './grpc.interface'
import { PassportConfig } from './passport.interface' import { PassportConfig } from './passport.interface'
import { RedisConfig } from './redis.interface' import { RedisConfig } from './redis.interface'
import { TelegramConfig } from './telegram.interface'
export interface AllConfigs { export interface AllConfigs {
database: DatabaseConfig database: DatabaseConfig
grpc: GrpcConfig grpc: GrpcConfig
redis: RedisConfig redis: RedisConfig
passport: PassportConfig passport: PassportConfig
telegram: TelegramConfig
} }

View File

@ -0,0 +1,6 @@
export interface TelegramConfig {
telegramBotId: string
telegramBotToken: string
telegramBotUsername: string
telegramRedirectOrigin: string
}

View File

@ -2,3 +2,4 @@ export * from './grpc.validator'
export * from './database.validator' export * from './database.validator'
export * from './redis.validator' export * from './redis.validator'
export * from './passport.validator' export * from './passport.validator'
export * from './telegram.validator'

View File

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

View File

@ -1,9 +1,7 @@
import { Module } from '@nestjs/common' 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 { OtpService } from '@/modules/otp/otp.service'
import { TokenService } from '@/modules/token/token.service'
import { UserRepository } from '@/shared/utils/repositories' import { UserRepository } from '@/shared/utils/repositories'
import { AuthController } from './auth.controller' import { AuthController } from './auth.controller'
@ -11,13 +9,13 @@ import { AuthRepository } from './auth.repository'
import { AuthService } from './auth.service' import { AuthService } from './auth.service'
@Module({ @Module({
imports: [
PassportModule.registerAsync({
useFactory: getPassportConfig,
inject: [ConfigService]
})
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthRepository, UserRepository, OtpService] providers: [
AuthService,
AuthRepository,
UserRepository,
OtpService,
TokenService
]
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { RpcException } from '@nestjs/microservices' import { RpcException } from '@nestjs/microservices'
import { Account } from '@prisma/generated/client' import { Account } from '@prisma/generated/client'
import { RpcStatus } from '@teacinema/common' import { RpcStatus } from '@teacinema/common'
@ -11,33 +10,22 @@ import {
VerifyOtpRequest, VerifyOtpRequest,
VerifyOtpResponse VerifyOtpResponse
} from '@teacinema/contracts/gen/auth' } from '@teacinema/contracts/gen/auth'
import { PassportService } from '@teacinema/passport'
import { AllConfigs } from '@/config'
import { OtpService } from '@/modules/otp/otp.service' import { OtpService } from '@/modules/otp/otp.service'
import { UserRepository } from '@/shared/utils/repositories' import { UserRepository } from '@/shared/utils/repositories'
import { TokenService } from '../token/token.service'
import { AuthRepository } from './auth.repository' import { AuthRepository } from './auth.repository'
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly ACCESS_TOKEN_TTL: number
private readonly REFRESH_TOKEN_TTL: number
public constructor( public constructor(
private readonly configService: ConfigService<AllConfigs>,
private readonly authRepository: AuthRepository, private readonly authRepository: AuthRepository,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly otpService: OtpService, private readonly otpService: OtpService,
private readonly passportService: PassportService private readonly tokenService: TokenService
) { ) {}
this.ACCESS_TOKEN_TTL = configService.get('passport.accessTtl', {
infer: true
})
this.REFRESH_TOKEN_TTL = configService.get('passport.refreshTtl', {
infer: true
})
}
public async sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> { public async sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
const { identifier, type } = data const { identifier, type } = data
@ -99,39 +87,21 @@ export class AuthService {
}) })
} }
return this.generateTokens(account.id) return this.tokenService.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 }
} }
public refresh(data: RefreshRequest): RefreshResponse { public refresh(data: RefreshRequest): RefreshResponse {
const { refreshToken } = data const { refreshToken } = data
const result = this.passportService.verify(refreshToken) const result = this.tokenService.verify(refreshToken)
if (!result.valid) { if (!result.valid) {
throw new RpcException({ throw new RpcException({
code: RpcStatus.UNAUTHENTICATED, code: RpcStatus.UNAUTHENTICATED,
// @ts-ignore // @ts-expect-error -- TODO: fixme
details: result.reason details: result.reason
}) })
} }
return this.generateTokens(result.userId) return this.tokenService.generateTokens(result.userId)
} }
} }

View File

@ -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<TelegramVerifyResponse> {
return this.telegramService.verify(data)
}
}

View File

@ -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 {}

View File

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

View File

@ -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<AllConfigs>,
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<string, string>) {
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
}
}

View File

@ -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 {}

View File

@ -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<AllConfigs>,
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)
}
}

View File

@ -1383,9 +1383,9 @@
integrity sha512-q3DURJbSk3k8MNWFIYaSM4LEcBgPbWa+HJmBz/nzYT4kuYitJVSXxpZ97kr0Ea+81AZwAU3JhQKlkK0SBWUi0A== integrity sha512-q3DURJbSk3k8MNWFIYaSM4LEcBgPbWa+HJmBz/nzYT4kuYitJVSXxpZ97kr0Ea+81AZwAU3JhQKlkK0SBWUi0A==
"@teacinema/contracts@^1.0.0": "@teacinema/contracts@^1.0.0":
version "1.0.7" version "1.0.8"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.7/contracts-1.0.7.tgz#2c75a3118e4126d30f5c8193c4518bac3f1812ff" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.8/contracts-1.0.8.tgz#4e31c3a370b0808629d9c93900c5f27958ef82e6"
integrity sha512-lE14PO/yBphYCu0BGFGyGAyvJ74v+ACz34MXA3fFo/PwThPVpudBReSG6oRyUACRybkN882GKXFU6LrJXvGbrQ== integrity sha512-xyPyifbVwWelgo4MsvhhRl//To+b0Tnp8DtANCzwLBUhwPj3ZG19QgfPB7Ih7PsBU5TD6F80aNlITDxJbFgkFQ==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"