fix(auth): use explicit public. schema for all login/register queries to prevent search_path contamination
This commit is contained in:
parent
c2ba432341
commit
00357d855d
|
|
@ -54,7 +54,29 @@ export class AuthService {
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId: 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) {
|
if (!user || !user.isActive) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
@ -103,14 +125,18 @@ export class AuthService {
|
||||||
throw new BadRequestException('Email or phone is required');
|
throw new BadRequestException('Email or phone is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check uniqueness
|
// Check uniqueness — always query public.users explicitly to avoid search_path issues
|
||||||
if (email) {
|
if (email) {
|
||||||
const existing = await this.userRepository.findByEmail(email);
|
const rows = await this.dataSource.query(
|
||||||
if (existing) throw new ConflictException('Email already registered');
|
`SELECT id FROM public.users WHERE email = $1 LIMIT 1`, [email],
|
||||||
|
);
|
||||||
|
if (rows.length > 0) throw new ConflictException('Email already registered');
|
||||||
}
|
}
|
||||||
if (phone) {
|
if (phone) {
|
||||||
const existing = await this.userRepository.findByPhone(phone);
|
const rows = await this.dataSource.query(
|
||||||
if (existing) throw new ConflictException('Phone number already registered');
|
`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);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
@ -200,6 +226,35 @@ export class AuthService {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const schemaName = `it0_t_${slug}`;
|
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();
|
const user = new User();
|
||||||
user.id = userId;
|
user.id = userId;
|
||||||
user.tenantId = slug;
|
user.tenantId = slug;
|
||||||
|
|
@ -209,28 +264,6 @@ export class AuthService {
|
||||||
user.name = name;
|
user.name = name;
|
||||||
user.roles = [RoleType.ADMIN];
|
user.roles = [RoleType.ADMIN];
|
||||||
user.isActive = true;
|
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);
|
const tokens = this.generateTokens(user);
|
||||||
return {
|
return {
|
||||||
|
|
@ -445,10 +478,13 @@ export class AuthService {
|
||||||
throw new UnauthorizedException('Invalid token type');
|
throw new UnauthorizedException('Invalid token type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findById(payload.sub);
|
const rows = await this.dataSource.query(
|
||||||
if (!user || !user.isActive) {
|
`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');
|
throw new UnauthorizedException('User not found or inactive');
|
||||||
}
|
}
|
||||||
|
const user = Object.assign(new User(), rows[0]) as User;
|
||||||
|
|
||||||
return this.generateTokens(user);
|
return this.generateTokens(user);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue