diff --git a/package.json b/package.json index a0f9aec..01af836 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@nestjs/platform-express": "^11.0.1", "africastalking": "^0.7.8", "bcrypt": "^6.0.0", + "dotenv": "^17.2.4", "ioredis": "^5.9.2", "nodemailer": "^8.0.1", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e39eeb..c73745c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: bcrypt: specifier: ^6.0.0 version: 6.0.0 + dotenv: + specifier: ^17.2.4 + version: 17.2.4 ioredis: specifier: ^5.9.2 version: 5.9.2 @@ -1594,6 +1597,10 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4826,6 +4833,8 @@ snapshots: dotenv@17.2.3: {} + dotenv@17.2.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..4a10051 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,15 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AuthController } from './auth.controller'; +import { OtpService } from './otp.service'; +import { SmsService } from './sms.service'; +import { MailService } from './mail.service'; +import { AppRedisModule } from './redis.provider'; @Module({ - imports: [], - controllers: [AppController], - providers: [AppService], + imports: [AppRedisModule], + controllers: [AuthController], + providers: [OtpService,SmsService,MailService], }) export class AppModule {} diff --git a/src/auth.controller.ts b/src/auth.controller.ts new file mode 100644 index 0000000..4514a65 --- /dev/null +++ b/src/auth.controller.ts @@ -0,0 +1,18 @@ +// auth.controller.ts +import { Body, Controller, Post } from '@nestjs/common'; +import { OtpService } from './otp.service'; + +@Controller('auth') +export class AuthController { + constructor(private readonly otpService: OtpService) {} + + @Post('request-otp') + request(@Body() body: { identifier: string }) { + return this.otpService.requestOtp(body.identifier); + } + + @Post('verify-otp') + verify(@Body() body: { identifier: string; otp: string }) { + return this.otpService.verifyOtp(body.identifier, body.otp); + } +} diff --git a/src/mail.service.ts b/src/mail.service.ts new file mode 100644 index 0000000..519640d --- /dev/null +++ b/src/mail.service.ts @@ -0,0 +1,25 @@ +// mail.service.ts +import { Injectable } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; + +@Injectable() +export class MailService { + private transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: true, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } + }); + + async sendOtp(email: string, otp: string) { + await this.transporter.sendMail({ + from: '"Your App" ', + to: email, + subject: 'Your OTP Code', + text: `Your verification code is ${otp}. It expires in 5 minutes.` + }); + } +} diff --git a/src/main.ts b/src/main.ts index f76bc8d..bb1a519 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,11 @@ import { NestFactory } from '@nestjs/core'; +import { config } from 'dotenv'; import { AppModule } from './app.module'; +config(); +console.log('API Key:', process.env.AT_API_KEY); +console.log('Username:', process.env.AT_USERNAME); + async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(process.env.PORT ?? 3000); diff --git a/src/otp.service.ts b/src/otp.service.ts new file mode 100644 index 0000000..0bdc00a --- /dev/null +++ b/src/otp.service.ts @@ -0,0 +1,74 @@ +// otp.service.ts +import { BadRequestException, Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Redis } from 'ioredis'; +import { randomInt } from 'crypto'; +import * as bcrypt from 'bcrypt'; +import { SmsService } from './sms.service'; +import { MailService } from './mail.service'; + +@Injectable() +export class OtpService { + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly smsService: SmsService, + private readonly mailService: MailService + ) {} + + async requestOtp(identifier: string) { + const isEmail = this.isEmail(identifier); + const isPhone = this.isPhone(identifier); + + if (!isEmail && !isPhone) { + throw new BadRequestException('Invalid email or phone number'); + } + + const cooldownKey = `otp:cooldown:${identifier}`; + const isCooling = await this.redis.get(cooldownKey); + if (isCooling) { + throw new HttpException('Please wait before requesting another OTP', HttpStatus.TOO_MANY_REQUESTS); + } + + const otp = randomInt(100000, 999999).toString(); + const hash = await bcrypt.hash(otp, 10); + + const otpKey = `otp:code:${identifier}`; + + await this.redis.set(otpKey, hash, 'EX', 300); + await this.redis.set(cooldownKey, '1', 'EX', 60); + + if (isEmail) { + await this.mailService.sendOtp(identifier, otp); + } else { + await this.smsService.sendOtp(identifier, otp); + } + + return { message: 'OTP sent' }; + } + + async verifyOtp(identifier: string, otp: string) { + const key = `otp:code:${identifier}`; + const hash = await this.redis.get(key); + + if (!hash) { + throw new BadRequestException('OTP expired or invalid'); + } + + const isValid = await bcrypt.compare(otp, hash); + if (!isValid) { + throw new BadRequestException('OTP expired or invalid'); + } + + await this.redis.del(key); + + return { message: 'OTP verified' }; + } + + private isEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + } + + private isPhone(value: string): boolean { + return /^\+2547\d{8}$/.test(value); + } +} diff --git a/src/redis.provider.ts b/src/redis.provider.ts new file mode 100644 index 0000000..3ffc0b7 --- /dev/null +++ b/src/redis.provider.ts @@ -0,0 +1,14 @@ +// redis.provider.ts +import { Module } from '@nestjs/common'; +import { RedisModule } from '@nestjs-modules/ioredis'; + +@Module({ + imports: [ + RedisModule.forRoot({ + type: 'single', + url: process.env.REDIS_URL + }) + ], + exports: [RedisModule] +}) +export class AppRedisModule {} diff --git a/src/sms.service.ts b/src/sms.service.ts new file mode 100644 index 0000000..9f19577 --- /dev/null +++ b/src/sms.service.ts @@ -0,0 +1,22 @@ +// sms.service.ts +import { Injectable } from '@nestjs/common'; +import AfricasTalking from 'africastalking'; + +@Injectable() +export class SmsService { + private client; + + constructor() { + this.client = AfricasTalking({ + apiKey: process.env.AT_API_KEY, + username: process.env.AT_USERNAME + }); + } + + async sendOtp(phone: string, otp: string) { + await this.client.SMS.send({ + to: [phone], + message: `Your verification code is ${otp}. Expires in 5 minutes.` + }); + } +}