feat: add otp code sending and verification

This commit is contained in:
Sergey Krylov 2026-01-24 15:33:43 +03:00
parent 62b06982ae
commit bbbfb889ff
8 changed files with 147 additions and 13 deletions

View File

@ -4,13 +4,15 @@ import { ConfigModule } from '@nestjs/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 { AuthModule } from './modules/auth/auth.module' import { AuthModule } from './modules/auth/auth.module'
import { OtpModule } from './modules/otp/otp.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, PrismaModule,
RedisModule, RedisModule,
AuthModule AuthModule,
OtpModule
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@ -2,7 +2,9 @@ import { Controller } from '@nestjs/common'
import { GrpcMethod } from '@nestjs/microservices' import { GrpcMethod } from '@nestjs/microservices'
import type { import type {
SendOtpRequest, SendOtpRequest,
SendOtpResponse SendOtpResponse,
VerifyOtpRequest,
VerifyOtpResponse
} from '@teacinema/contracts/gen/auth' } from '@teacinema/contracts/gen/auth'
import { AuthService } from './auth.service' import { AuthService } from './auth.service'
@ -15,4 +17,9 @@ export class AuthController {
public sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> { public sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
return this.authService.sendOtp(data) return this.authService.sendOtp(data)
} }
@GrpcMethod('AuthService', 'VerifyOtp')
public VerifyOtp(data: VerifyOtpRequest): Promise<VerifyOtpResponse> {
return this.authService.verifyOtp(data)
}
} }

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { OtpService } from '@/modules/otp/otp.service'
import { AuthController } from './auth.controller' import { AuthController } from './auth.controller'
import { AuthRepository } from './auth.repository' import { AuthRepository } from './auth.repository'
import { AuthService } from './auth.service' import { AuthService } from './auth.service'
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthRepository] providers: [AuthService, AuthRepository, OtpService]
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { Account } from '@prisma/generated/client' import { Account } from '@prisma/generated/client'
import { AccountCreateInput } from '@prisma/generated/models/Account' import {
AccountCreateInput,
AccountUpdateInput
} from '@prisma/generated/models/Account'
import { PrismaService } from '@/infra/prisma/prisma.service' import { PrismaService } from '@/infra/prisma/prisma.service'
@ -23,4 +26,11 @@ export class AuthRepository {
public createAccount(data: AccountCreateInput) { public createAccount(data: AccountCreateInput) {
return this.prismaService.account.create({ data }) return this.prismaService.account.create({ data })
} }
public updateAccount(id: Account['id'], data: AccountUpdateInput) {
return this.prismaService.account.update({
where: { id },
data
})
}
} }

View File

@ -1,12 +1,23 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { Account } from '@prisma/generated/client' import { Account } from '@prisma/generated/client'
import { SendOtpRequest, SendOtpResponse } from '@teacinema/contracts/gen/auth' import {
SendOtpRequest,
SendOtpResponse,
VerifyOtpRequest,
VerifyOtpResponse
} from '@teacinema/contracts/gen/auth'
import { OtpService } from '@/modules/otp/otp.service'
import { AuthRepository } from './auth.repository' import { AuthRepository } from './auth.repository'
@Injectable() @Injectable()
export class AuthService { export class AuthService {
public constructor(private readonly authRepository: AuthRepository) {} public constructor(
private readonly authRepository: AuthRepository,
private readonly otpService: OtpService
) {}
public async sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> { public async sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
const { identifier, type } = data const { identifier, type } = data
@ -28,6 +39,43 @@ export class AuthService {
}) })
} }
const code = await this.otpService.send(
identifier,
type as 'email' | 'phone'
)
console.debug('CODE', code)
return { ok: true } return { ok: true }
} }
public async verifyOtp(data: VerifyOtpRequest): Promise<VerifyOtpResponse> {
const { identifier, code, type } = data
await this.otpService.verify(identifier, code, type as 'email' | 'phone')
const isPhoneType = type === 'phone'
const isEmailType = type === 'email'
const account = isPhoneType
? await this.authRepository.findByPhone(identifier)
: await this.authRepository.findByEmail(identifier)
if (!account) {
throw new RpcException('Account not found')
}
if (isPhoneType && !account.isPhoneVerified) {
await this.authRepository.updateAccount(account.id, {
isPhoneVerified: true
})
}
if (isEmailType && !account.isEmailVerified) {
await this.authRepository.updateAccount(account.id, {
isEmailVerified: true
})
}
return { accessToken: '123456', refreshToken: '123456' }
}
} }

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { OtpService } from './otp.service'
@Module({
providers: [OtpService]
})
export class OtpModule {}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { createHash } from 'node:crypto'
import { RedisService } from '@/infra/redis/redis.service'
@Injectable()
export class OtpService {
public constructor(private readonly redisService: RedisService) {}
public async send(indentifier: string, type: 'email' | 'phone') {
const { code, hash } = this.generateCode()
await this.redisService.set(
`otp:${type}:${indentifier}`,
hash,
'EX',
60 * 5
)
return code
}
public async verify(
indentifier: string,
code: string,
type: 'email' | 'phone'
) {
const stroredHash = await this.redisService.get(
`otp:${type}:${indentifier}`
)
if (!stroredHash) {
throw new RpcException('Invalid or expired code')
}
const hash = createHash('sha256').update(code).digest('hex')
if (stroredHash !== hash) {
throw new RpcException('Invalid or expired code')
}
await this.redisService.del(`otp:${type}:${indentifier}`)
}
private generateCode() {
// todo посмотреть готовые бибилотеки для генерации friendly otp
const code = Math.floor(100_000 + Math.random() * 900_000)
const hash = createHash('sha256').update(String(code)).digest('hex')
return { code, hash }
}
}

View File

@ -1355,9 +1355,9 @@
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@teacinema/contracts@^1.0.0": "@teacinema/contracts@^1.0.0":
version "1.0.0" version "1.0.1"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.0/contracts-1.0.0.tgz#9395c331ec94b604d1946d26b683dd93e04a6225" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.1/contracts-1.0.1.tgz#25021fecb1d33d0505fd11ca3d8201660cb1bcbe"
integrity sha512-zhYE0SIWjJl+ujp+yf0dT3ua4hWpR19gy//CwVcLO9bvfh+bZRYttlXE6XXWGucHv6208+sNNdkpwSwVyKyqNg== integrity sha512-gvdbQ3Xowr4/h9Kq2P+pO2lr9vyWvIvRoU74mG3LSq2IiKKuwQUTxRaJnzI5MdtNqiq0WIUMo30cfqnBzNAJ1A==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"
@ -1365,9 +1365,9 @@
ts-proto "2.11.0" ts-proto "2.11.0"
"@teacinema/core@^1.0.8": "@teacinema/core@^1.0.8":
version "1.0.8" version "1.0.9"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcore/-/1.0.8/core-1.0.8.tgz#928bd0e84be7857c479f3e914b36c457e3922779" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcore/-/1.0.9/core-1.0.9.tgz#deec0675c6cc03fe2511a6b8dc5d6b6763998708"
integrity sha512-cd8mArdF7vzScaDAP7QRD0N91oT9kZdc/J32HU4LSyyr+y600CBtC7PhDCHrDiWte22mqirw27cKD7ujW1eoTA== integrity sha512-HM6JMOsvWL8xC2RxabIk4zdNjfRsBf9Gc9UEGk7O7X/ZLEvnt7hOVE7p3gsTuMQXeUK4qNjltm2vOU5U8M2ufA==
dependencies: dependencies:
"@trivago/prettier-plugin-sort-imports" "^5.2.2" "@trivago/prettier-plugin-sort-imports" "^5.2.2"
@ -4188,11 +4188,16 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash@4.17.21, lodash@^4.17.21: lodash@4.17.21:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lodash@^4.17.21:
version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
log-symbols@^4.1.0: log-symbols@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"