diff --git a/package.json b/package.json index a4a0d1e..de40d9a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@keyv/redis": "^5.1.6", "@nestjs/axios": "^4.0.1", + "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.1.28", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.28", @@ -31,9 +33,11 @@ "@nestjs/typeorm": "^11.0.3", "axios": "^1.18.1", "bcrypt": "^6.0.0", + "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "joi": "^18.2.3", + "keyv": "^5.6.0", "ms": "^2.1.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0db64f..ff2ce86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@keyv/redis': + specifier: ^5.1.6 + version: 5.1.6(keyv@5.6.0) '@nestjs/axios': specifier: ^4.0.1 version: 4.0.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.18.1)(rxjs@7.8.2) + '@nestjs/cache-manager': + specifier: ^3.1.3 + version: 3.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(cache-manager@7.2.9)(keyv@5.6.0)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.1.28 version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -41,6 +47,9 @@ importers: bcrypt: specifier: ^6.0.0 version: 6.0.0 + cache-manager: + specifier: ^7.2.9 + version: 7.2.9 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -50,6 +59,9 @@ importers: joi: specifier: ^18.2.3 version: 18.2.3 + keyv: + specifier: ^5.6.0 + version: 5.6.0 ms: specifier: ^2.1.3 version: 2.1.3 @@ -352,6 +364,9 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -706,6 +721,15 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@keyv/redis@5.1.6': + resolution: {integrity: sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -726,6 +750,15 @@ packages: axios: ^1.3.1 rxjs: ^7.0.0 + '@nestjs/cache-manager@3.1.3': + resolution: {integrity: sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + '@nestjs/cli@11.0.24': resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==} engines: {node: '>= 20.11'} @@ -869,6 +902,18 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@redis/client@5.12.1': + resolution: {integrity: sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -1467,6 +1512,9 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cache-manager@7.2.9: + resolution: {integrity: sha512-d4vceEyYe95gPxEyQchlEOH9vJlkNRW8G6gzFzzMTxJK9PahYMhC9chrEqgZN0HulROjgw3IzmWVNk7Q7ytiGw==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1550,6 +1598,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2064,10 +2116,17 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2381,6 +2440,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -3690,6 +3752,11 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@colors/colors@1.5.0': optional: true @@ -4157,6 +4224,18 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/redis@5.1.6(keyv@5.6.0)': + dependencies: + '@redis/client': 5.12.1 + cluster-key-slot: 1.1.2 + hookified: 1.15.1 + keyv: 5.6.0 + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + + '@keyv/serialize@1.1.1': {} + '@lukeed/csprng@1.1.0': {} '@microsoft/tsdoc@0.16.0': {} @@ -4174,6 +4253,14 @@ snapshots: axios: 1.18.1 rxjs: 7.8.2 + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(cache-manager@7.2.9)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.9 + keyv: 5.6.0 + rxjs: 7.8.2 + '@nestjs/cli@11.0.24(@types/node@22.20.1)(prettier@3.9.5)': dependencies: '@angular-devkit/core': 19.2.27(chokidar@4.0.3) @@ -4332,6 +4419,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@redis/client@5.12.1': + dependencies: + cluster-key-slot: 1.1.2 + '@scarf/scarf@1.4.0': {} '@sinclair/typebox@0.34.52': {} @@ -5000,6 +5091,11 @@ snapshots: bytes@3.1.2: {} + cache-manager@7.2.9: + dependencies: + '@cacheable/utils': 2.5.0 + keyv: 5.6.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5073,6 +5169,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -5592,10 +5690,16 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.4: dependencies: function-bind: 1.1.2 + hookified@1.15.1: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -6100,6 +6204,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + leven@3.1.0: {} levn@0.4.1: diff --git a/src/app.module.ts b/src/app.module.ts index 0fcf029..a4cefb9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,21 +10,34 @@ import { OrganModule } from './modules/organ/organ.module'; import { ComplaintModule } from './modules/complaint/complaint.module'; import { AttachmentModule } from './modules/attachment/attachment.module'; import { ReplyModule } from './modules/reply/reply.module'; +import { CacheModule } from '@nestjs/cache-manager'; +import KeyvRedis from '@keyv/redis'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - validationSchema + validationSchema, + }), + CacheModule.registerAsync({ + isGlobal: true, + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + stores: [new KeyvRedis(config.getOrThrow('REDIS_URL'))], + }), }), TypeOrmModule.forRootAsync(databaseConfig()), - HttpModule.register({global: true, timeout: 10000, headers: {"Content-Type": "application/json"}}), + HttpModule.register({ + global: true, + timeout: 10000, + headers: { 'Content-Type': 'application/json' }, + }), UsersModule, AuthModule, OrganModule, ComplaintModule, AttachmentModule, - ReplyModule + ReplyModule, ], }) export class AppModule {} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1e21ec5..d16bb50 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -9,6 +9,7 @@ import { OtpRepository } from './repositories/otp.repository'; import { UsersModule } from 'src/modules/users/users.module'; import { PassportModule } from '@nestjs/passport'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { OtpService } from './otp.service'; @Module({ imports: [ @@ -19,7 +20,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; UsersModule, ], controllers: [AuthController], - providers: [AuthService, OtpRepository, JwtStrategy], - exports: [JwtModule, PassportModule] + providers: [AuthService, OtpService, OtpRepository, JwtStrategy], + exports: [JwtModule, PassportModule], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 4509f32..a043884 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -12,6 +12,7 @@ import { UsersService } from 'src/modules/users/users.service'; import * as bcrypt from 'bcrypt'; import { JwtService } from '@nestjs/jwt'; import ms from 'ms'; +import { OtpService } from './otp.service'; @Injectable() export class AuthService { @@ -19,13 +20,13 @@ export class AuthService { @Inject(SMS_CONFIG) private smsConfig: ISmsConfigs, private readonly smsService: SmsService, private readonly configService: ConfigService, - private readonly OtpRepository: OtpRepository, private readonly userService: UsersService, private readonly jwtService: JwtService, + private readonly otpService: OtpService ) {} async sendOTP(phone: string) { - const otp = this.generateOtp(); + const otp = await this.otpService.generate(phone); try { await this.smsService.sendSms({ @@ -39,21 +40,6 @@ export class AuthService { ], }); - await this.OtpRepository.update( - { phoneNumber: phone, status: OtpStatus.PENDING }, - { status: OtpStatus.EXPIRED }, - ); - - await this.OtpRepository.save( - this.OtpRepository.create({ - phoneNumber: phone, - code: otp, - expirationDate: new Date(Date.now() + 2 * 60 * 1000), - status: OtpStatus.PENDING, - numberOfTries: 0, - }), - ); - return { otp }; } catch (error) { if (this.configService.get('NODE_ENV') !== 'production') { @@ -65,48 +51,7 @@ export class AuthService { } async verifyOTP(dto: VerifyOtpDto) { - const otp = await this.OtpRepository.findOne({ - where: { phoneNumber: dto.phoneNumber, status: OtpStatus.PENDING }, - order: { - createdAt: 'DESC', - }, - }); - - if (!otp) { - throw new BadRequestException(OTPMessages.OTP_NOT_FOUND); - } - - if (otp.expirationDate < new Date()) { - otp.status = OtpStatus.EXPIRED; - - await this.OtpRepository.save(otp); - - throw new BadRequestException(OTPMessages.OTP_EXPIRED); - } - - if (otp.code !== dto.code) { - otp.numberOfTries++; - - if (otp.numberOfTries >= 3) { - otp.status = OtpStatus.EXPIRED; - - await this.OtpRepository.save(otp); - - throw new BadRequestException(OTPMessages.OTP_TOO_MANY_TRIES); - } - - await this.OtpRepository.save(otp); - - throw new BadRequestException(OTPMessages.OTP_INVALID); - } - - otp.status = OtpStatus.VERIFIED; - - await this.OtpRepository.save(otp); - } - - private generateOtp(): string { - return Math.floor(100000 + Math.random() * 900000).toString(); + await this.otpService.verify(dto.phoneNumber, dto.code); } async checkLoginCredentials(credential: LoginCredentialDto): Promise { diff --git a/src/modules/auth/otp.service.ts b/src/modules/auth/otp.service.ts new file mode 100644 index 0000000..e4bc8cd --- /dev/null +++ b/src/modules/auth/otp.service.ts @@ -0,0 +1,74 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Cache } from 'cache-manager'; +import { OTPMessages } from 'src/common/enums/messages.enum'; + +interface OtpCache { + code: string; + tries: number; +} + +@Injectable() +export class OtpService { + private readonly TTL: number; + + constructor( + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + private readonly configService: ConfigService, + ) { + this.TTL = this.configService.getOrThrow('CACHE_TTL'); + } + + private getKey(phoneNumber: string) { + return `otp:${phoneNumber}`; + } + + async generate(phoneNumber: string): Promise { + const code = Math.floor(100000 + Math.random() * 900000).toString(); + + const value: OtpCache = { + code, + tries: 0, + }; + + await this.cache.set(this.getKey(phoneNumber), value, this.TTL); + + return code; + } + + async verify(phoneNumber: string, code: string): Promise { + const otp = await this.cache.get(this.getKey(phoneNumber)); + + if (!otp) { + throw new BadRequestException(OTPMessages.OTP_EXPIRED); + } + + if (otp.tries >= 5) { + await this.cache.del(this.getKey(phoneNumber)); + + throw new BadRequestException(OTPMessages.OTP_TOO_MANY_TRIES); + } + + if (otp.code !== code) { + otp.tries++; + + await this.cache.set(this.getKey(phoneNumber), otp, this.TTL); + + throw new BadRequestException(OTPMessages.OTP_INVALID); + } + + await this.cache.del(this.getKey(phoneNumber)); + + return true; + } + + async delete(phoneNumber: string): Promise { + await this.cache.del(this.getKey(phoneNumber)); + } + + async exists(phoneNumber: string): Promise { + return !!(await this.cache.get(this.getKey(phoneNumber))); + } +}