diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc4d151..dd4aa15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,20 @@ on: push: branches: [main] pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' jobs: build-and-test: @@ -64,6 +78,30 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm test -- --passWithNoTests + env: + NODE_ENV: test + DB_HOST: localhost + DB_PORT: '5432' + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: nexafx_test + JWT_SECRET: test-jwt-secret-at-least-32-characters-long + REFRESH_TOKEN_SECRET: test-refresh-secret-at-least-32-chars + OTP_SECRET: test-otp-secret-at-least-32-characters-long + MAIL_HOST: localhost + MAIL_PORT: '587' + MAIL_USER: test@example.com + MAIL_PASSWORD: testpassword + MAIL_FROM: noreply@example.com + DISABLE_BULL: 'true' - name: Build run: npm run build diff --git a/package-lock.json b/package-lock.json index 89132b0..cc1efdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/schedule": "^6.1.3", @@ -29,6 +30,8 @@ "@nestjs/websockets": "^11.1.17", "@types/nodemailer": "^7.0.11", "@types/socket.io-client": "^1.4.36", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", "axios": "^1.16.1", "bull": "^4.12.0", "cache-manager": "^7.2.8", @@ -40,6 +43,8 @@ "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "nodemailer": "^8.0.4", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pdf-lib": "^1.17.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.2", @@ -57,10 +62,13 @@ "@nestjs/testing": "^11.0.1", "@swc/cli": "^0.6.0", "@swc/core": "^1.10.7", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "better-sqlite3": "^12.6.2", "eslint": "^9.18.0", @@ -2653,6 +2661,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.12", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", @@ -3662,6 +3680,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3869,6 +3894,38 @@ "@types/node": "*" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -5380,6 +5437,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/better-sqlite3": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", @@ -11068,6 +11131,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11151,6 +11250,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pdf-lib": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", @@ -14087,6 +14191,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index d5cee13..d21d89d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/schedule": "^6.1.3", @@ -45,6 +46,8 @@ "@nestjs/websockets": "^11.1.17", "@types/nodemailer": "^7.0.11", "@types/socket.io-client": "^1.4.36", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", "axios": "^1.16.1", "bull": "^4.12.0", "cache-manager": "^7.2.8", @@ -56,6 +59,8 @@ "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "nodemailer": "^8.0.4", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pdf-lib": "^1.17.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.2", @@ -73,10 +78,13 @@ "@nestjs/testing": "^11.0.1", "@swc/cli": "^0.6.0", "@swc/core": "^1.10.7", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "better-sqlite3": "^12.6.2", "eslint": "^9.18.0", diff --git a/src/app.controller.ts b/src/app.controller.ts index 484b837..f42ef67 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,6 +1,7 @@ import { SkipThrottle } from '@nestjs/throttler'; import { Controller, Get, Post, Body } from '@nestjs/common'; import { AppService } from './app.service'; +import { Public } from './auth/decorators/public.decorator'; import { CreateCatDto } from './dtos/create-cat.dto'; @SkipThrottle() @@ -8,6 +9,7 @@ import { CreateCatDto } from './dtos/create-cat.dto'; export class AppController { constructor(private readonly appService: AppService) {} + @Public() @Get() getHello(): string { return this.appService.getHello(); diff --git a/src/app.module.ts b/src/app.module.ts index 3824e4f..d0aa030 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,9 @@ import { AuthModule } from './auth/auth.module'; import { DocumentsModule } from './documents/documents.module'; import { MailModule, MailQueueModule } from './mail/mail.module'; import { IdempotencyModule } from './idempotency/idempotency.module'; +import { AuthModule } from './auth/auth.module'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { RolesGuard } from './auth/guards/roles.guard'; import { NotificationQueueModule } from './notification/notification.module'; import { TransactionQueueModule } from './transaction/transaction.module'; import { AccountClosureModule } from './users/account-closure.module'; @@ -148,6 +151,14 @@ const enableBull = ] : []), IdempotencyModule, + AuthModule, + ], + controllers: [AppController], + providers: [ + AppService, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], AccountClosureModule, AuthModule, EventEmitterModule.forRoot({ global: true }), diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index da27f8e..fce5fc6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,3 +1,45 @@ +import { Body, Controller, HttpCode, HttpStatus, Post, Request } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { Public } from './decorators/public.decorator'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('register') + register(@Body() body: { email: string; password: string }) { + return this.authService.register(body.email, body.password); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('login') + login(@Body() body: { email: string; password: string }) { + return this.authService.login(body.email, body.password); + } + + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @Post('logout') + logout(@Request() req: { user: { id: string } }) { + return this.authService.logout(req.user.id); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('verify-email') + verifyEmail(@Body() body: { email: string; otp: string }) { + return this.authService.verifyEmail(body.email, body.otp); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('resend-verification') + resendVerification(@Body() body: { email: string }) { + return this.authService.resendVerification(body.email); import { Body, Controller, Headers, Ip, Post } from '@nestjs/common'; import { AuthService, CredentialsDto, RegisterDto } from './auth.service'; diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index f28aa58..ec867de 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,3 +1,23 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { User } from '../users/user.entity'; +import { MailModule } from '../mail/mail.module'; + +@Module({ + imports: [ + PassportModule, + JwtModule.register({}), + TypeOrmModule.forFeature([User]), + MailModule, + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], import { Module, forwardRef } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ef3904a..ccb6c46 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,3 +1,17 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import { User } from '../users/user.entity'; +import { MailService } from '../mail/mail.service'; +import { Role } from './enums/role.enum'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { createHash, timingSafeEqual } from 'crypto'; @@ -27,6 +41,128 @@ const hashPassword = (password: string): string => @Injectable() export class AuthService { constructor( + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly jwtService: JwtService, + private readonly mailService: MailService, + ) {} + + async register(email: string, password: string) { + const existing = await this.userRepo.findOne({ where: { email } }); + if (existing) throw new ConflictException('Email already registered'); + + const hashed = await bcrypt.hash(password, 12); + const otp = this.generateOtp(); + const expiry = new Date(Date.now() + 5 * 60 * 1000); + + const user = this.userRepo.create({ + email, + password: hashed, + role: Role.USER, + emailVerificationOtp: otp, + emailVerificationOtpExpiry: expiry, + }); + await this.userRepo.save(user); + await this.mailService.sendVerificationOtp(email, otp); + + return { message: 'Registration successful. Check your email for the OTP.' }; + } + + async login(email: string, password: string) { + const user = await this.userRepo.findOne({ where: { email } }); + if (!user || !(await bcrypt.compare(password, user.password))) { + throw new UnauthorizedException('Invalid credentials'); + } + + const tokens = await this.issueTokens(user); + const hashedRefresh = await bcrypt.hash(tokens.refreshToken, 10); + await this.userRepo.update(user.id, { refreshToken: hashedRefresh }); + + return tokens; + } + + async logout(userId: string) { + await this.userRepo.update(userId, { refreshToken: null }); + return { message: 'Logged out successfully' }; + } + + async verifyEmail(email: string, otp: string) { + const user = await this.userRepo.findOne({ where: { email } }); + if (!user) throw new NotFoundException('User not found'); + if (user.isEmailVerified) throw new BadRequestException('Email already verified'); + + if ( + !user.emailVerificationOtp || + user.emailVerificationOtp !== otp || + !user.emailVerificationOtpExpiry || + user.emailVerificationOtpExpiry < new Date() + ) { + throw new BadRequestException('Invalid or expired OTP'); + } + + await this.userRepo.update(user.id, { + isEmailVerified: true, + emailVerificationOtp: null, + emailVerificationOtpExpiry: null, + }); + + return { message: 'Email verified successfully' }; + } + + async resendVerification(email: string) { + const user = await this.userRepo.findOne({ where: { email } }); + if (!user) throw new NotFoundException('User not found'); + if (user.isEmailVerified) throw new BadRequestException('Email already verified'); + + const now = new Date(); + const windowStart = user.resendWindowStart; + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + const count = + windowStart && windowStart > oneHourAgo ? user.resendCount : 0; + + if (count >= 3) { + throw new BadRequestException('Resend limit reached. Try again in an hour.'); + } + + const otp = this.generateOtp(); + const expiry = new Date(Date.now() + 5 * 60 * 1000); + + await this.userRepo.update(user.id, { + emailVerificationOtp: otp, + emailVerificationOtpExpiry: expiry, + resendCount: count + 1, + resendWindowStart: count === 0 ? now : user.resendWindowStart, + }); + + await this.mailService.sendVerificationOtp(email, otp); + return { message: 'Verification email resent' }; + } + + private generateOtp(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } + + private async issueTokens(user: User) { + const payload = { + sub: user.id, + email: user.email, + role: user.role, + isEmailVerified: user.isEmailVerified, + }; + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(payload, { + secret: process.env.JWT_SECRET, + expiresIn: parseInt(process.env.JWT_EXPIRY || '3600', 10), + }), + this.jwtService.signAsync(payload, { + secret: process.env.REFRESH_TOKEN_SECRET, + expiresIn: parseInt(process.env.REFRESH_TOKEN_EXPIRY || '604800', 10), + }), + ]); + + return { accessToken, refreshToken }; private readonly usersService: UsersService, private readonly termsService: TermsAcceptanceService, private readonly jwtService: JwtService, diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e108a9c --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '../enums/role.enum'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/enums/role.enum.ts b/src/auth/enums/role.enum.ts new file mode 100644 index 0000000..c6de305 --- /dev/null +++ b/src/auth/enums/role.enum.ts @@ -0,0 +1,5 @@ +export enum Role { + USER = 'user', + ADMIN = 'admin', + COMPLIANCE = 'compliance', +} diff --git a/src/auth/guards/email-verified.guard.ts b/src/auth/guards/email-verified.guard.ts new file mode 100644 index 0000000..5f27992 --- /dev/null +++ b/src/auth/guards/email-verified.guard.ts @@ -0,0 +1,19 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; + +@Injectable() +export class EmailVerifiedGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const { user } = context.switchToHttp().getRequest(); + if (!user?.isEmailVerified) { + throw new ForbiddenException( + 'Email verification required to access this resource', + ); + } + return true; + } +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..a5939fd --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,31 @@ +import { + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } + + handleRequest(err: any, user: any) { + if (err || !user) { + throw err ?? new UnauthorizedException('Invalid or expired token'); + } + return user; + } +} diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..098fc72 --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,28 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { Role } from '../enums/role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles?.length) return true; + + const { user } = context.switchToHttp().getRequest(); + if (!requiredRoles.includes(user?.role)) { + throw new ForbiddenException('Insufficient permissions'); + } + return true; + } +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..98d3c85 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +export interface JwtPayload { + sub: string; + email: string; + role: string; + isEmailVerified: boolean; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET || '', + }); + } + + validate(payload: JwtPayload) { + return { + id: payload.sub, + email: payload.email, + role: payload.role, + isEmailVerified: payload.isEmailVerified, + }; + } +} diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 7c11702..1edd18d 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; import { compile, TemplateDelegate } from 'handlebars'; import { readFileSync } from 'fs'; import { join } from 'path'; @@ -90,6 +91,31 @@ class WelcomeTemplateDto { @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); + private transporter: nodemailer.Transporter; + + constructor() { + this.transporter = nodemailer.createTransport({ + host: process.env.MAIL_HOST, + port: parseInt(process.env.MAIL_PORT || '587', 10), + secure: process.env.MAIL_SECURE === 'true', + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASSWORD, + }, + }); + } + + async sendVerificationOtp(email: string, otp: string): Promise { + try { + await this.transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: 'Verify your NexaFx email', + html: `

Your verification code is: ${otp}

This code expires in 5 minutes.

`, + }); + } catch (err) { + this.logger.error(`Failed to send verification email to ${email}`, err); + } private readonly cache = new Map(); renderEmailVerification(payload: EmailVerificationTemplateDto): string { diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index 0ff2246..1a185e2 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -1,5 +1,11 @@ import { Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Role } from '../auth/enums/role.enum'; Column, PrimaryGeneratedColumn, CreateDateColumn, @@ -29,6 +35,28 @@ export class User { email: string; @Column() + password: string; + + @Column({ type: 'enum', enum: Role, default: Role.USER }) + role: Role; + + @Column({ default: false }) + isEmailVerified: boolean; + + @Column({ nullable: true, type: 'varchar' }) + emailVerificationOtp: string | null; + + @Column({ nullable: true, type: 'timestamptz' }) + emailVerificationOtpExpiry: Date | null; + + @Column({ default: 0 }) + resendCount: number; + + @Column({ nullable: true, type: 'timestamptz' }) + resendWindowStart: Date | null; + + @Column({ nullable: true, type: 'varchar' }) + refreshToken: string | null; passwordHash: string; @Column()