From bbbfb889ff73c8d136a5458387c44d303709de14 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 24 Jan 2026 15:33:43 +0300 Subject: [PATCH] feat: add otp code sending and verification --- src/app.module.ts | 4 ++- src/modules/auth/auth.controller.ts | 9 ++++- src/modules/auth/auth.module.ts | 4 ++- src/modules/auth/auth.repository.ts | 12 ++++++- src/modules/auth/auth.service.ts | 52 +++++++++++++++++++++++++++-- src/modules/otp/otp.module.ts | 8 +++++ src/modules/otp/otp.service.ts | 52 +++++++++++++++++++++++++++++ yarn.lock | 19 +++++++---- 8 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 src/modules/otp/otp.module.ts create mode 100644 src/modules/otp/otp.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 16b1f84..6580e4e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,13 +4,15 @@ import { ConfigModule } from '@nestjs/config' import { PrismaModule } from './infra/prisma/prisma.module' import { RedisModule } from './infra/redis/redis.module' import { AuthModule } from './modules/auth/auth.module' +import { OtpModule } from './modules/otp/otp.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, RedisModule, - AuthModule + AuthModule, + OtpModule ] }) export class AppModule {} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 3bea983..7717fa4 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,7 +2,9 @@ import { Controller } from '@nestjs/common' import { GrpcMethod } from '@nestjs/microservices' import type { SendOtpRequest, - SendOtpResponse + SendOtpResponse, + VerifyOtpRequest, + VerifyOtpResponse } from '@teacinema/contracts/gen/auth' import { AuthService } from './auth.service' @@ -15,4 +17,9 @@ export class AuthController { public sendOtp(data: SendOtpRequest): Promise { return this.authService.sendOtp(data) } + + @GrpcMethod('AuthService', 'VerifyOtp') + public VerifyOtp(data: VerifyOtpRequest): Promise { + return this.authService.verifyOtp(data) + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index ce68699..4910359 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common' +import { OtpService } from '@/modules/otp/otp.service' + import { AuthController } from './auth.controller' import { AuthRepository } from './auth.repository' import { AuthService } from './auth.service' @Module({ controllers: [AuthController], - providers: [AuthService, AuthRepository] + providers: [AuthService, AuthRepository, OtpService] }) export class AuthModule {} diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index 0777b8d..8c13bb2 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common' 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' @@ -23,4 +26,11 @@ export class AuthRepository { public createAccount(data: AccountCreateInput) { return this.prismaService.account.create({ data }) } + + public updateAccount(id: Account['id'], data: AccountUpdateInput) { + return this.prismaService.account.update({ + where: { id }, + data + }) + } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 33bebb7..ab4b632 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,12 +1,23 @@ import { Injectable } from '@nestjs/common' +import { RpcException } from '@nestjs/microservices' 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' @Injectable() 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 { 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 } } + + public async verifyOtp(data: VerifyOtpRequest): Promise { + 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' } + } } diff --git a/src/modules/otp/otp.module.ts b/src/modules/otp/otp.module.ts new file mode 100644 index 0000000..c84a327 --- /dev/null +++ b/src/modules/otp/otp.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' + +import { OtpService } from './otp.service' + +@Module({ + providers: [OtpService] +}) +export class OtpModule {} diff --git a/src/modules/otp/otp.service.ts b/src/modules/otp/otp.service.ts new file mode 100644 index 0000000..2ced3b9 --- /dev/null +++ b/src/modules/otp/otp.service.ts @@ -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 } + } +} diff --git a/yarn.lock b/yarn.lock index 050b5eb..a0042af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1355,9 +1355,9 @@ integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== "@teacinema/contracts@^1.0.0": - version "1.0.0" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.0/contracts-1.0.0.tgz#9395c331ec94b604d1946d26b683dd93e04a6225" - integrity sha512-zhYE0SIWjJl+ujp+yf0dT3ua4hWpR19gy//CwVcLO9bvfh+bZRYttlXE6XXWGucHv6208+sNNdkpwSwVyKyqNg== + version "1.0.1" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.1/contracts-1.0.1.tgz#25021fecb1d33d0505fd11ca3d8201660cb1bcbe" + integrity sha512-gvdbQ3Xowr4/h9Kq2P+pO2lr9vyWvIvRoU74mG3LSq2IiKKuwQUTxRaJnzI5MdtNqiq0WIUMo30cfqnBzNAJ1A== dependencies: "@nestjs/microservices" "^11.1.12" protoc "33.4.0" @@ -1365,9 +1365,9 @@ ts-proto "2.11.0" "@teacinema/core@^1.0.8": - version "1.0.8" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcore/-/1.0.8/core-1.0.8.tgz#928bd0e84be7857c479f3e914b36c457e3922779" - integrity sha512-cd8mArdF7vzScaDAP7QRD0N91oT9kZdc/J32HU4LSyyr+y600CBtC7PhDCHrDiWte22mqirw27cKD7ujW1eoTA== + version "1.0.9" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcore/-/1.0.9/core-1.0.9.tgz#deec0675c6cc03fe2511a6b8dc5d6b6763998708" + integrity sha512-HM6JMOsvWL8xC2RxabIk4zdNjfRsBf9Gc9UEGk7O7X/ZLEvnt7hOVE7p3gsTuMQXeUK4qNjltm2vOU5U8M2ufA== dependencies: "@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" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@4.17.21, lodash@^4.17.21: +lodash@4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 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: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"