Add password login, set-password, business images migration, and user serialization.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 12:10:30 +03:30
co-authored by Cursor
parent 715ca585a8
commit 97a9ee022f
11 changed files with 290 additions and 36 deletions
+35
View File
@@ -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<string> {
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<boolean> {
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;
}
}
+25
View File
@@ -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,
};
}
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateBusinessImages1789040000000 implements MigrationInterface {
name = 'CreateBusinessImages1789040000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
'DROP INDEX IF EXISTS "IDX_business_images_businessId"',
);
await queryRunner.query('DROP TABLE IF EXISTS "business_images"');
}
}
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserPasswordHash1789041000000 implements MigrationInterface {
name = 'AddUserPasswordHash1789041000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "passwordHash" character varying(255)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "users"
DROP COLUMN IF EXISTS "passwordHash"
`);
}
}
+3
View File
@@ -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[];
+16
View File
@@ -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) {
+91 -24
View File
@@ -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<UserEntity, 'id' | 'mobile' | 'fullName' | 'role' | 'isActive'>;
user: ReturnType<typeof serializeUser>;
};
@Injectable()
@@ -113,7 +120,7 @@ export class AuthService {
return response;
}
async verifyOtp(dto: VerifyOtpDto): Promise<VerifyOtpResponse> {
async verifyOtp(dto: VerifyOtpDto): Promise<AuthSessionResponse> {
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<AuthSessionResponse> {
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<AuthSessionResponse> {
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<UserEntity> {
@@ -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)) {
@@ -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;
}
+22
View File
@@ -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;
}
+5 -4
View File
@@ -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<UserEntity[]> {
findAll(): Promise<PublicUser[]> {
return this.service.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): Promise<UserEntity> {
findOne(@Param('id') id: string): Promise<PublicUser> {
return this.service.findOne(id);
}
@@ -33,7 +34,7 @@ export class UsersController {
update(
@Param('id') id: string,
@Body() payload: UpdateUserDto,
): Promise<UserEntity> {
): Promise<PublicUser> {
return this.service.update(id, payload);
}
}
+26 -8
View File
@@ -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<UserEntity>,
) {}
findAll(): Promise<UserEntity[]> {
return this.repo.find({ order: { createdAt: 'DESC' } });
async findAll(): Promise<PublicUser[]> {
const users = await this.repo
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.orderBy('user.createdAt', 'DESC')
.getMany();
return users.map(serializeUser);
}
async findOne(id: string): Promise<UserEntity> {
const user = await this.repo.findOne({ where: { id } });
async findOne(id: string): Promise<PublicUser> {
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<UserEntity> {
const user = await this.findOne(id);
async update(id: string, dto: UpdateUserDto): Promise<PublicUser> {
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);
}
}