fix(auth): use explicit public. schema for all login/register queries to prevent search_path contamination

This commit is contained in:
hailin 2026-03-07 03:50:05 -08:00
parent c2ba432341
commit 00357d855d
1 changed files with 66 additions and 30 deletions

View File

@ -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);
}