diff --git a/src/common/utils/password.util.ts b/src/common/utils/password.util.ts new file mode 100644 index 0000000..9024e2a --- /dev/null +++ b/src/common/utils/password.util.ts @@ -0,0 +1,35 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'crypto'; +import { promisify } from 'util'; + +const scryptAsync = promisify(scrypt); +const KEY_LENGTH = 64; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; + return `scrypt:${salt}:${derived.toString('hex')}`; +} + +export async function verifyPassword( + password: string, + stored: string, +): Promise { + const parts = stored.split(':'); + if (parts.length !== 3 || parts[0] !== 'scrypt') { + return false; + } + const [, salt, hashHex] = parts; + if (!salt || !hashHex) { + return false; + } + try { + const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; + const expected = Buffer.from(hashHex, 'hex'); + if (derived.length !== expected.length) { + return false; + } + return timingSafeEqual(derived, expected); + } catch { + return false; + } +} diff --git a/src/common/utils/user-serialize.util.ts b/src/common/utils/user-serialize.util.ts new file mode 100644 index 0000000..900312d --- /dev/null +++ b/src/common/utils/user-serialize.util.ts @@ -0,0 +1,25 @@ +import { UserEntity } from '../../entities/user.entity'; + +export type PublicUser = { + id: string; + mobile: string; + fullName: string | null; + role: string; + isActive: boolean; + hasPassword: boolean; + createdAt?: Date; + updatedAt?: Date; +}; + +export function serializeUser(user: UserEntity): PublicUser { + return { + id: user.id, + mobile: user.mobile, + fullName: user.fullName, + role: user.role, + isActive: user.isActive, + hasPassword: Boolean(user.passwordHash), + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} diff --git a/src/database/migrations/1789040000000-CreateBusinessImages.ts b/src/database/migrations/1789040000000-CreateBusinessImages.ts new file mode 100644 index 0000000..98f2368 --- /dev/null +++ b/src/database/migrations/1789040000000-CreateBusinessImages.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateBusinessImages1789040000000 implements MigrationInterface { + name = 'CreateBusinessImages1789040000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "business_images" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "businessId" uuid NOT NULL, + "url" character varying(500) NOT NULL, + "originalName" character varying(255), + "sortOrder" integer NOT NULL DEFAULT 0, + "isCover" boolean NOT NULL DEFAULT false, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_business_images_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_business_images_business" + FOREIGN KEY ("businessId") REFERENCES "businesses"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_business_images_businessId" + ON "business_images" ("businessId") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX IF EXISTS "IDX_business_images_businessId"', + ); + await queryRunner.query('DROP TABLE IF EXISTS "business_images"'); + } +} diff --git a/src/database/migrations/1789041000000-AddUserPasswordHash.ts b/src/database/migrations/1789041000000-AddUserPasswordHash.ts new file mode 100644 index 0000000..49cc20b --- /dev/null +++ b/src/database/migrations/1789041000000-AddUserPasswordHash.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserPasswordHash1789041000000 implements MigrationInterface { + name = 'AddUserPasswordHash1789041000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "passwordHash" character varying(255) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "users" + DROP COLUMN IF EXISTS "passwordHash" + `); + } +} diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 8f5022f..dca113a 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -32,6 +32,9 @@ export class UserEntity { @Column({ default: true }) isActive!: boolean; + @Column({ type: 'varchar', nullable: true, length: 255, select: false }) + passwordHash!: string | null; + @OneToMany(() => BusinessEntity, (business) => business.owner) ownedBusinesses!: BusinessEntity[]; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 5bf48d4..201b811 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,7 +2,9 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthUser } from './auth.types'; import { CurrentUser } from './decorators/current-user.decorator'; +import { LoginPasswordDto } from './dto/login-password.dto'; import { RequestOtpDto } from './dto/request-otp.dto'; +import { SetPasswordDto } from './dto/set-password.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @@ -20,6 +22,20 @@ export class AuthController { return this.auth.verifyOtp(payload); } + @Post('login-password') + loginWithPassword(@Body() payload: LoginPasswordDto) { + return this.auth.loginWithPassword(payload); + } + + @Post('set-password') + @UseGuards(JwtAuthGuard) + setPassword( + @CurrentUser() user: AuthUser, + @Body() payload: SetPasswordDto, + ) { + return this.auth.setPassword(user.id, payload); + } + @Get('me') @UseGuards(JwtAuthGuard) me(@CurrentUser() user: AuthUser) { diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 76d6313..22b2e3d 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -16,10 +16,17 @@ import { isValidIranMobile, normalizeIranMobile, } from '../../common/utils/iran-mobile.util'; +import { + hashPassword, + verifyPassword, +} from '../../common/utils/password.util'; +import { serializeUser } from '../../common/utils/user-serialize.util'; import { AuthOtpEntity } from '../../entities/auth-otp.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { JwtPayload } from './auth.types'; +import { LoginPasswordDto } from './dto/login-password.dto'; import { RequestOtpDto } from './dto/request-otp.dto'; +import { SetPasswordDto } from './dto/set-password.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; import { SmsService } from './sms.service'; @@ -28,9 +35,9 @@ type RequestOtpResponse = { otpCode?: string; }; -type VerifyOtpResponse = { +type AuthSessionResponse = { accessToken: string; - user: Pick; + user: ReturnType; }; @Injectable() @@ -113,7 +120,7 @@ export class AuthService { return response; } - async verifyOtp(dto: VerifyOtpDto): Promise { + async verifyOtp(dto: VerifyOtpDto): Promise { const mobile = this.normalizeMobile(dto.mobile); const code = String(dto.code ?? '').trim(); @@ -148,24 +155,94 @@ export class AuthService { await this.otps.save(otp); const user = await this.findOrCreateCustomer(mobile); - const payload: JwtPayload = { - sub: user.id, - mobile: user.mobile, - role: user.role, - }; - return { - accessToken: await this.jwt.signAsync(payload), - user: this.serializeUser(user), - }; + return this.issueSession(user); + } + + async loginWithPassword(dto: LoginPasswordDto): Promise { + const mobile = this.normalizeMobile(dto.mobile); + const user = await this.users + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.mobile = :mobile', { mobile }) + .getOne(); + + if (!user || !user.isActive) { + throw new UnauthorizedException('Invalid mobile or password'); + } + if (!user.passwordHash) { + throw new UnauthorizedException( + 'Password is not set. Sign in with OTP first, then set a password in your profile.', + ); + } + + const valid = await verifyPassword(dto.password, user.passwordHash); + if (!valid) { + throw new UnauthorizedException('Invalid mobile or password'); + } + + return this.issueSession(user); + } + + async setPassword(userId: string, dto: SetPasswordDto) { + if (dto.password !== dto.confirmPassword) { + throw new BadRequestException('Password confirmation does not match'); + } + + const user = await this.users + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id: userId }) + .getOne(); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found'); + } + + if (user.passwordHash) { + if (!dto.currentPassword) { + throw new BadRequestException('Current password is required'); + } + const valid = await verifyPassword(dto.currentPassword, user.passwordHash); + if (!valid) { + throw new UnauthorizedException('Current password is incorrect'); + } + } + + user.passwordHash = await hashPassword(dto.password); + const saved = await this.users.save(user); + return serializeUser(saved); } async me(userId: string) { - const user = await this.users.findOne({ where: { id: userId } }); + const user = await this.users + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id: userId }) + .getOne(); if (!user) { throw new UnauthorizedException('User not found'); } - return this.serializeUser(user); + return serializeUser(user); + } + + private async issueSession(user: UserEntity): Promise { + const full = await this.users + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id: user.id }) + .getOne(); + const profile = full ?? user; + + const payload: JwtPayload = { + sub: profile.id, + mobile: profile.mobile, + role: profile.role, + }; + return { + accessToken: await this.jwt.signAsync(payload), + user: serializeUser(profile), + }; } private async findOrCreateCustomer(mobile: string): Promise { @@ -193,16 +270,6 @@ export class AuthService { ); } - private serializeUser(user: UserEntity) { - return { - id: user.id, - mobile: user.mobile, - fullName: user.fullName, - role: user.role, - isActive: user.isActive, - }; - } - private normalizeMobile(mobile: string): string { const normalized = normalizeIranMobile(mobile); if (!isValidIranMobile(normalized)) { diff --git a/src/modules/auth/dto/login-password.dto.ts b/src/modules/auth/dto/login-password.dto.ts new file mode 100644 index 0000000..4587540 --- /dev/null +++ b/src/modules/auth/dto/login-password.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator'; + +export class LoginPasswordDto { + @ApiProperty({ example: '09122222222' }) + @IsIranMobile() + mobile!: string; + + @ApiProperty({ example: 'mySecurePass123' }) + @IsString() + @MinLength(1) + @MaxLength(72) + password!: string; +} diff --git a/src/modules/auth/dto/set-password.dto.ts b/src/modules/auth/dto/set-password.dto.ts new file mode 100644 index 0000000..4ebd1b3 --- /dev/null +++ b/src/modules/auth/dto/set-password.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class SetPasswordDto { + @ApiPropertyOptional({ description: 'Required when changing an existing password' }) + @IsOptional() + @IsString() + @MaxLength(72) + currentPassword?: string; + + @ApiProperty({ example: 'mySecurePass123', minLength: 8 }) + @IsString() + @MinLength(8) + @MaxLength(72) + password!: string; + + @ApiProperty({ example: 'mySecurePass123' }) + @IsString() + @MinLength(8) + @MaxLength(72) + confirmPassword!: string; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index ea6d94b..f638445 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -6,7 +6,8 @@ import { Patch, UseGuards, } from '@nestjs/common'; -import { UserEntity, UserRole } from '../../entities/user.entity'; +import { PublicUser } from '../../common/utils/user-serialize.util'; +import { UserRole } from '../../entities/user.entity'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -20,12 +21,12 @@ export class UsersController { constructor(private readonly service: UsersService) {} @Get() - findAll(): Promise { + findAll(): Promise { return this.service.findAll(); } @Get(':id') - findOne(@Param('id') id: string): Promise { + findOne(@Param('id') id: string): Promise { return this.service.findOne(id); } @@ -33,7 +34,7 @@ export class UsersController { update( @Param('id') id: string, @Body() payload: UpdateUserDto, - ): Promise { + ): Promise { return this.service.update(id, payload); } } diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 7ac929a..2b26718 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { PublicUser, serializeUser } from '../../common/utils/user-serialize.util'; import { UserEntity } from '../../entities/user.entity'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -11,20 +12,36 @@ export class UsersService { private readonly repo: Repository, ) {} - findAll(): Promise { - return this.repo.find({ order: { createdAt: 'DESC' } }); + async findAll(): Promise { + const users = await this.repo + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .orderBy('user.createdAt', 'DESC') + .getMany(); + return users.map(serializeUser); } - async findOne(id: string): Promise { - const user = await this.repo.findOne({ where: { id } }); + async findOne(id: string): Promise { + const user = await this.repo + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id }) + .getOne(); if (!user) { throw new NotFoundException('User not found'); } - return user; + return serializeUser(user); } - async update(id: string, dto: UpdateUserDto): Promise { - const user = await this.findOne(id); + async update(id: string, dto: UpdateUserDto): Promise { + const user = await this.repo + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :id', { id }) + .getOne(); + if (!user) { + throw new NotFoundException('User not found'); + } if (dto.fullName !== undefined) { user.fullName = dto.fullName; @@ -36,6 +53,7 @@ export class UsersService { user.isActive = dto.isActive; } - return this.repo.save(user); + const saved = await this.repo.save(user); + return serializeUser(saved); } }