Compare commits
5 Commits
4cad7e2f17
...
bbbfb889ff
| Author | SHA1 | Date | |
|---|---|---|---|
| bbbfb889ff | |||
| 62b06982ae | |||
| 4992f5651e | |||
| 9c7b81bf11 | |||
| f9dc39f29b |
2
.gitignore
vendored
2
.gitignore
vendored
@ -54,3 +54,5 @@ pids
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
/prisma/generated
|
||||
|
||||
@ -30,6 +30,11 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-redundant-type-constituents": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
@ -28,7 +30,12 @@
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/microservices": "^11.1.12",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"@teacinema/contracts": "^1.0.0",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"ioredis": "^5.9.2",
|
||||
"prisma": "^7.3.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
12
prisma.config.ts
Normal file
12
prisma.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import 'dotenv-expand/config'
|
||||
import { defineConfig } from 'prisma/config'
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations'
|
||||
},
|
||||
datasource: {
|
||||
url: process.env['POSTGRES_URI']
|
||||
}
|
||||
})
|
||||
24
prisma/schema.prisma
Normal file
24
prisma/schema.prisma
Normal file
@ -0,0 +1,24 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated"
|
||||
moduleFormat = "cjs"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(nanoid())
|
||||
|
||||
phone String? @unique
|
||||
email String? @unique
|
||||
|
||||
isPhoneVerified Boolean @default(false) @map("is_phone_verified")
|
||||
isEmailVerified Boolean @default(false) @map("is_email_verified")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("accounts")
|
||||
}
|
||||
@ -1,8 +1,18 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
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: [AuthModule]
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
OtpModule
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
10
src/infra/prisma/prisma.module.ts
Normal file
10
src/infra/prisma/prisma.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common'
|
||||
|
||||
import { PrismaService } from './prisma.service'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
export class PrismaModule {}
|
||||
59
src/infra/prisma/prisma.service.ts
Normal file
59
src/infra/prisma/prisma.service.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit
|
||||
} from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '@prisma/generated/client'
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name)
|
||||
|
||||
public constructor(private readonly configService: ConfigService) {
|
||||
super({
|
||||
adapter: new PrismaPg({
|
||||
user: configService.getOrThrow<string>('POSTGRES_USERNAME'),
|
||||
password: configService.getOrThrow<string>('POSTGRES_PASSWORD'),
|
||||
host: configService.getOrThrow<string>('POSTGRES_HOST'),
|
||||
port: configService.getOrThrow<number>('POSTGRES_PORT'),
|
||||
database: configService.getOrThrow<string>('POSTGRES_DATABASE_NAME')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async onModuleInit() {
|
||||
const start = Date.now()
|
||||
|
||||
this.logger.log('Prisma connecting...')
|
||||
|
||||
try {
|
||||
await this.$connect()
|
||||
|
||||
const ms = Date.now() - start
|
||||
|
||||
this.logger.log(`Prisma connected in ${ms}ms`)
|
||||
} catch (e) {
|
||||
this.logger.error('Prisma connection error ', e)
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleDestroy() {
|
||||
this.logger.log('Prisma disconnecting...')
|
||||
|
||||
try {
|
||||
await this.$disconnect()
|
||||
|
||||
this.logger.log('Prisma disconnected')
|
||||
} catch (e) {
|
||||
this.logger.error('Prisma disconnection error ', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/infra/redis/redis.module.ts
Normal file
10
src/infra/redis/redis.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common'
|
||||
|
||||
import { RedisService } from './redis.service'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService]
|
||||
})
|
||||
export class RedisModule {}
|
||||
64
src/infra/redis/redis.service.ts
Normal file
64
src/infra/redis/redis.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit
|
||||
} from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import Redis from 'ioredis'
|
||||
|
||||
@Injectable()
|
||||
export class RedisService
|
||||
extends Redis
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(RedisService.name)
|
||||
|
||||
public constructor(private readonly configService: ConfigService) {
|
||||
super({
|
||||
username: configService.getOrThrow<string>('REDIS_USERNAME'),
|
||||
host: configService.getOrThrow<string>('REDIS_HOST'),
|
||||
port: configService.getOrThrow<number>('REDIS_PORT'),
|
||||
password: configService.getOrThrow<string>('REDIS_PASSWORD'),
|
||||
maxRetriesPerRequest: 5,
|
||||
enableOfflineQueue: true
|
||||
})
|
||||
}
|
||||
|
||||
public onModuleInit() {
|
||||
const start = Date.now()
|
||||
|
||||
this.on('connect', () => {
|
||||
this.logger.log('Redis connecting...')
|
||||
})
|
||||
|
||||
this.on('ready', () => {
|
||||
this.logger.log(`Redis connected in ${Date.now() - start}ms`)
|
||||
})
|
||||
|
||||
this.on('error', e => {
|
||||
this.logger.error('Redis connection error', {
|
||||
error: e.message || e
|
||||
})
|
||||
})
|
||||
|
||||
this.on('close', () => {
|
||||
this.logger.warn('Redis connection closed')
|
||||
})
|
||||
|
||||
this.on('reconnecting', () => {
|
||||
this.logger.log('Redis reconnecting')
|
||||
})
|
||||
}
|
||||
|
||||
public async onModuleDestroy() {
|
||||
this.logger.log('Redis disconnecting...')
|
||||
|
||||
try {
|
||||
await this.quit()
|
||||
this.logger.log('Redis disconnected')
|
||||
} catch (e) {
|
||||
this.logger.error('Redis disconnecting error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
@ -12,10 +14,12 @@ export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@GrpcMethod('AuthService', 'SendOtp')
|
||||
public sendOtp(data: SendOtpRequest): SendOtpResponse {
|
||||
console.log('Incoming OTP request', data)
|
||||
return {
|
||||
ok: true
|
||||
}
|
||||
public sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
|
||||
return this.authService.sendOtp(data)
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'VerifyOtp')
|
||||
public VerifyOtp(data: VerifyOtpRequest): Promise<VerifyOtpResponse> {
|
||||
return this.authService.verifyOtp(data)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +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]
|
||||
providers: [AuthService, AuthRepository, OtpService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
36
src/modules/auth/auth.repository.ts
Normal file
36
src/modules/auth/auth.repository.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { Account } from '@prisma/generated/client'
|
||||
import {
|
||||
AccountCreateInput,
|
||||
AccountUpdateInput
|
||||
} from '@prisma/generated/models/Account'
|
||||
|
||||
import { PrismaService } from '@/infra/prisma/prisma.service'
|
||||
|
||||
@Injectable()
|
||||
export class AuthRepository {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public findByPhone(phone: NonNullable<Account['phone']>) {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: { phone }
|
||||
})
|
||||
}
|
||||
|
||||
public findByEmail(email: NonNullable<Account['email']>) {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { RpcException } from '@nestjs/microservices'
|
||||
import { Account } from '@prisma/generated/client'
|
||||
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 {}
|
||||
export class AuthService {
|
||||
public constructor(
|
||||
private readonly authRepository: AuthRepository,
|
||||
private readonly otpService: OtpService
|
||||
) {}
|
||||
|
||||
public async sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
|
||||
const { identifier, type } = data
|
||||
|
||||
let account: Account | null = null
|
||||
const isPhoneType = type === 'phone'
|
||||
const isEmailType = type === 'email'
|
||||
|
||||
if (isPhoneType) {
|
||||
account = await this.authRepository.findByPhone(identifier)
|
||||
} else {
|
||||
account = await this.authRepository.findByEmail(identifier)
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
account = await this.authRepository.createAccount({
|
||||
email: isEmailType ? identifier : undefined,
|
||||
phone: isPhoneType ? identifier : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const code = await this.otpService.send(
|
||||
identifier,
|
||||
type as 'email' | 'phone'
|
||||
)
|
||||
console.debug('CODE', code)
|
||||
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
8
src/modules/otp/otp.module.ts
Normal file
8
src/modules/otp/otp.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
|
||||
import { OtpService } from './otp.service'
|
||||
|
||||
@Module({
|
||||
providers: [OtpService]
|
||||
})
|
||||
export class OtpModule {}
|
||||
52
src/modules/otp/otp.service.ts
Normal file
52
src/modules/otp/otp.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,11 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@prisma/generated": ["./prisma/generated"],
|
||||
"@prisma/generated/*": ["./prisma/generated/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user