From 00357d855dfb71d1a14f0f48d3616ec18be02c18 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 03:50:05 -0800 Subject: [PATCH] fix(auth): use explicit public. schema for all login/register queries to prevent search_path contamination --- .../src/application/services/auth.service.ts | 96 +++++++++++++------ 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index 0713af4..da533de 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -54,7 +54,29 @@ export class AuthService { refreshToken: string; user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId: string }; }> { - const user = await this.userRepository.findByIdentifier(identifier); + // Always query public.users explicitly — avoids search_path contamination + const isPhone = /^[+\d][\d\s\-().]{6,}$/.test(identifier); + const rows = isPhone + ? await this.dataSource.query( + `SELECT * FROM public.users WHERE phone = $1 AND is_active = true LIMIT 1`, [identifier], + ) + : await this.dataSource.query( + `SELECT * FROM public.users WHERE email = $1 AND is_active = true LIMIT 1`, [identifier], + ); + if (!rows.length) { + // fallback: if phone not found try email and vice versa + const fallbackRows = isPhone + ? await this.dataSource.query( + `SELECT * FROM public.users WHERE email = $1 AND is_active = true LIMIT 1`, [identifier], + ) + : await this.dataSource.query( + `SELECT * FROM public.users WHERE phone = $1 AND is_active = true LIMIT 1`, [identifier], + ); + if (!fallbackRows.length) throw new UnauthorizedException('Invalid credentials'); + rows.push(...fallbackRows); + } + const rawUser = rows[0]; + const user = Object.assign(new User(), rawUser) as User; if (!user || !user.isActive) { throw new UnauthorizedException('Invalid credentials'); } @@ -103,14 +125,18 @@ export class AuthService { throw new BadRequestException('Email or phone is required'); } - // Check uniqueness + // Check uniqueness — always query public.users explicitly to avoid search_path issues if (email) { - const existing = await this.userRepository.findByEmail(email); - if (existing) throw new ConflictException('Email already registered'); + const rows = await this.dataSource.query( + `SELECT id FROM public.users WHERE email = $1 LIMIT 1`, [email], + ); + if (rows.length > 0) throw new ConflictException('Email already registered'); } if (phone) { - const existing = await this.userRepository.findByPhone(phone); - if (existing) throw new ConflictException('Phone number already registered'); + const rows = await this.dataSource.query( + `SELECT id FROM public.users WHERE phone = $1 LIMIT 1`, [phone], + ); + if (rows.length > 0) throw new ConflictException('Phone number already registered'); } const passwordHash = await bcrypt.hash(password, 12); @@ -200,6 +226,35 @@ export class AuthService { const now = new Date(); const schemaName = `it0_t_${slug}`; + // 3+4. Insert into both public.users (auth lookup) AND tenant schema (management) in one QR + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.startTransaction(); + + // a. Insert into public.users — explicit schema qualifier, no search_path dependency + await qr.query( + `INSERT INTO public.users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [userId, slug, email ?? null, phone ?? null, passwordHash, name, [RoleType.ADMIN], true, now, now], + ); + + // b. Insert into tenant schema for tenant-context management + await qr.query(`SET LOCAL search_path TO "${schemaName}", public`); + await qr.query( + `INSERT INTO users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [userId, slug, email ?? null, phone ?? null, passwordHash, name, [RoleType.ADMIN], true, now, now], + ); + + await qr.commitTransaction(); + } catch (err) { + await qr.rollbackTransaction(); + throw err; + } finally { + await qr.release(); + } + const user = new User(); user.id = userId; user.tenantId = slug; @@ -209,28 +264,6 @@ export class AuthService { user.name = name; user.roles = [RoleType.ADMIN]; user.isActive = true; - user.createdAt = now; - user.updatedAt = now; - await this.userRepository.save(user); // writes to public.users (auth lookup index) - - // 4. Also insert into the tenant schema for tenant-context management - const qr = this.dataSource.createQueryRunner(); - await qr.connect(); - try { - await qr.startTransaction(); - await qr.query(`SET LOCAL search_path TO "${schemaName}", public`); - await qr.query( - `INSERT INTO users (id, tenant_id, email, phone, password_hash, name, roles, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [userId, slug, email ?? null, phone ?? null, passwordHash, name, [RoleType.ADMIN], true, now, now], - ); - await qr.commitTransaction(); - } catch (err) { - await qr.rollbackTransaction(); - throw err; - } finally { - await qr.release(); - } const tokens = this.generateTokens(user); return { @@ -445,10 +478,13 @@ export class AuthService { throw new UnauthorizedException('Invalid token type'); } - const user = await this.userRepository.findById(payload.sub); - if (!user || !user.isActive) { + const rows = await this.dataSource.query( + `SELECT * FROM public.users WHERE id = $1 AND is_active = true LIMIT 1`, [payload.sub], + ); + if (!rows.length) { throw new UnauthorizedException('User not found or inactive'); } + const user = Object.assign(new User(), rows[0]) as User; return this.generateTokens(user); }