diff --git a/apps/api/package.json b/apps/api/package.json index 43f389c..0d8df50 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/passport": "^11.0.5", + "@nestjs/jwt": "^11.0.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.6", @@ -45,6 +46,7 @@ "ioredis": "^5.11.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "bcrypt": "^5.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1" diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index bf8b840..acd4098 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,82 +1,22 @@ -import { - Controller, - Post, - Body, - HttpCode, - HttpStatus, - BadRequestException, - UnauthorizedException, -} from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { RegisterDto } from './dto/register.dto'; -import { LoginDto } from './dto/login.dto'; +import { Controller, Post, Body } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { RegisterMerchantDto } from './dto/register-merchant.dto'; +import { LoginMerchantDto } from './dto/login-merchant.dto'; import { Public } from './decorators/public.decorator'; -import * as jwt from 'jsonwebtoken'; -import { randomBytes, scryptSync } from 'crypto'; - -function hashPassword(password: string): string { - const salt = randomBytes(16).toString('hex'); - const derived = scryptSync(password, salt, 64).toString('hex'); - return `${salt}:${derived}`; -} - -function verifyPassword(password: string, stored: string): boolean { - const [salt, key] = stored.split(':'); - if (!salt || !key) return false; - const derived = scryptSync(password, salt, 64).toString('hex'); - return derived === key; -} @Controller('auth') export class AuthController { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly authService: AuthService) {} - @Post('register') @Public() - @HttpCode(HttpStatus.CREATED) - async register(@Body() dto: RegisterDto) { - const existing = await this.prisma.merchant.findUnique({ where: { email: dto.email } }); - if (existing) { - throw new BadRequestException('Email already registered'); - } - - const passwordHash = hashPassword(dto.password); - const merchant = await this.prisma.merchant.create({ - data: { email: dto.email, passwordHash }, - }); - - const token = jwt.sign( - { merchant_id: merchant.id }, - process.env.JWT_SECRET ?? 'default-secret-change-me', - { - expiresIn: '1h', - }, - ); - - return { access_token: token }; + @Post('register') + async register(@Body() dto: RegisterMerchantDto) { + return this.authService.register(dto); } - @Post('login') @Public() - @HttpCode(HttpStatus.OK) - async login(@Body() dto: LoginDto) { - const merchant = await this.prisma.merchant.findUnique({ where: { email: dto.email } }); - if (!merchant) { - throw new UnauthorizedException('Invalid credentials'); - } - - if (!merchant.passwordHash || !verifyPassword(dto.password, merchant.passwordHash)) { - throw new UnauthorizedException('Invalid credentials'); - } - - const token = jwt.sign( - { merchant_id: merchant.id }, - process.env.JWT_SECRET ?? 'default-secret-change-me', - { - expiresIn: '1h', - }, - ); - - return { access_token: token }; + @Post('login') + async login(@Body() dto: LoginMerchantDto) { + return this.authService.login(dto); } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 6496c56..0dee6d0 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,14 +1,23 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ - imports: [PassportModule.register({ defaultStrategy: 'jwt' }), PrismaModule], + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: process.env.JWT_SECRET ?? 'default-secret-change-me', + signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '1h' }, + }), + PrismaModule, + ], controllers: [AuthController], - providers: [JwtStrategy, JwtAuthGuard], - exports: [JwtAuthGuard], + providers: [JwtStrategy, JwtAuthGuard, AuthService], + exports: [JwtAuthGuard, AuthService], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..afdc429 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,52 @@ +import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import { RegisterMerchantDto } from './dto/register-merchant.dto'; +import { LoginMerchantDto } from './dto/login-merchant.dto'; + +@Injectable() +export class AuthService { + constructor(private readonly prisma: PrismaService, private readonly jwtService: JwtService) {} + + private parseExpiresInToSeconds(value: string): number { + // Accepts values like '1h', '30m', or plain seconds '3600' + if (!value) return 3600; + if (/^\d+$/.test(value)) return parseInt(value, 10); + const m = value.match(/^(\d+)([smh])$/); + if (!m) return 3600; + const n = parseInt(m[1], 10); + const unit = m[2]; + if (unit === 'h') return n * 3600; + if (unit === 'm') return n * 60; + return n; + } + + async register(dto: RegisterMerchantDto) { + const existing = await this.prisma.merchant.findUnique({ where: { email: dto.email } }); + if (existing) throw new BadRequestException('Email already in use'); + + const hash = await bcrypt.hash(dto.password, 12); + + const merchant = await this.prisma.merchant.create({ + data: { email: dto.email, passwordHash: hash }, + }); + + return { merchant_id: merchant.id, email: merchant.email }; + } + + async login(dto: LoginMerchantDto) { + const merchant = await this.prisma.merchant.findUnique({ where: { email: dto.email } }); + if (!merchant) throw new UnauthorizedException('Invalid credentials'); + + const ok = await bcrypt.compare(dto.password, merchant.passwordHash); + if (!ok) throw new UnauthorizedException('Invalid credentials'); + + const payload = { merchant_id: merchant.id }; + const expiresInEnv = process.env.JWT_EXPIRES_IN || '1h'; + const access_token = this.jwtService.sign(payload, { expiresIn: expiresInEnv }); + const expires_in = this.parseExpiresInToSeconds(expiresInEnv); + + return { access_token, expires_in }; + } +} diff --git a/apps/api/src/auth/dto/login-merchant.dto.ts b/apps/api/src/auth/dto/login-merchant.dto.ts new file mode 100644 index 0000000..106db85 --- /dev/null +++ b/apps/api/src/auth/dto/login-merchant.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class LoginMerchantDto { + @IsEmail() + email: string; + + @IsString() + password: string; +} diff --git a/apps/api/src/auth/dto/register-merchant.dto.ts b/apps/api/src/auth/dto/register-merchant.dto.ts new file mode 100644 index 0000000..237b114 --- /dev/null +++ b/apps/api/src/auth/dto/register-merchant.dto.ts @@ -0,0 +1,13 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class RegisterMerchantDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; + + @IsString() + name?: string; +}