diff --git a/backend/migrations/012_encrypt_setup.sql b/backend/migrations/012_encrypt_setup.sql new file mode 100644 index 0000000..8577ca8 --- /dev/null +++ b/backend/migrations/012_encrypt_setup.sql @@ -0,0 +1,28 @@ +-- Migration 012: подготовка схемы для шифрования чувствительных полей +-- birth_date и passport_issue_date меняем с DATE на TEXT (для хранения зашифрованных значений) +-- Добавляем passport_search_token — HMAC-индекс для поиска без расшифровки + +-- ── guests ──────────────────────────────────────────────────────────────────── +ALTER TABLE guests + ALTER COLUMN birth_date TYPE TEXT USING birth_date::TEXT; + +ALTER TABLE guests + ADD COLUMN IF NOT EXISTS passport_search_token VARCHAR(64); + +CREATE INDEX IF NOT EXISTS idx_guests_passport_token + ON guests(hotel_id, passport_search_token) + WHERE passport_search_token IS NOT NULL; + +-- ── booking_guests ──────────────────────────────────────────────────────────── +ALTER TABLE booking_guests + ALTER COLUMN birth_date TYPE TEXT USING birth_date::TEXT; + +ALTER TABLE booking_guests + ALTER COLUMN passport_issue_date TYPE TEXT USING passport_issue_date::TEXT; + +ALTER TABLE booking_guests + ADD COLUMN IF NOT EXISTS passport_search_token VARCHAR(64); + +CREATE INDEX IF NOT EXISTS idx_booking_guests_passport_token + ON booking_guests(hotel_id, passport_search_token) + WHERE passport_search_token IS NOT NULL; diff --git a/backend/src/lib/crypto.ts b/backend/src/lib/crypto.ts new file mode 100644 index 0000000..c591e5a --- /dev/null +++ b/backend/src/lib/crypto.ts @@ -0,0 +1,51 @@ +import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'crypto' + +const KEY = Buffer.from(process.env.ENCRYPTION_KEY ?? '', 'hex') // 32 bytes +const HMAC = process.env.HMAC_KEY ?? '' + +if (process.env.NODE_ENV !== 'test' && (KEY.length !== 32 || !HMAC)) { + throw new Error('ENCRYPTION_KEY (64 hex chars) and HMAC_KEY must be set in environment') +} + +// ── Encrypt ─────────────────────────────────────────────────────────────────── +// Format: hex(iv 12b) : hex(authTag 16b) : hex(ciphertext) +export function encrypt(plaintext: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', KEY, iv) + const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return `${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}` +} + +// ── Decrypt ─────────────────────────────────────────────────────────────────── +export function decrypt(data: string): string { + const parts = data.split(':') + if (parts.length !== 3) return data // не зашифровано — вернуть как есть + const [ivHex, tagHex, encHex] = parts + if (ivHex.length !== 24) return data // не наш формат + const iv = Buffer.from(ivHex, 'hex') + const authTag = Buffer.from(tagHex, 'hex') + const enc = Buffer.from(encHex, 'hex') + const decipher = createDecipheriv('aes-256-gcm', KEY, iv) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8') +} + +// ── Null-safe helpers ───────────────────────────────────────────────────────── +export function encryptField(val: string | null | undefined): string | null { + if (!val) return null + return encrypt(val) +} + +export function decryptField(val: string | null | undefined): string | null { + if (!val) return null + try { return decrypt(val) } catch { return val } // старые незашифрованные данные +} + +// ── Passport search token — HMAC(series+number) ─────────────────────────────── +// Хранится рядом с зашифрованными полями для точного поиска без расшифровки +export function passportToken(seriesAndNumber: string): string { + return createHmac('sha256', HMAC) + .update(seriesAndNumber.toLowerCase().replace(/\s/g, '')) + .digest('hex') +} diff --git a/backend/src/routes/booking-guests.ts b/backend/src/routes/booking-guests.ts index 279eb3b..012fced 100644 --- a/backend/src/routes/booking-guests.ts +++ b/backend/src/routes/booking-guests.ts @@ -1,7 +1,8 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { encryptField, decryptField, passportToken } from '../lib/crypto' -type Params = { Params: { slug: string; bookingId: string } } +type Params = { Params: { slug: string; bookingId: string } } type ParamsWithId = { Params: { slug: string; bookingId: string; id: string } } type GuestBody = Partial<{ @@ -12,6 +13,18 @@ type GuestBody = Partial<{ nationality: string }> +// Расшифровывает чувствительные поля строки booking_guest перед отправкой клиенту +function decryptBg(row: Record) { + return { + ...row, + passport_series: decryptField(row.passport_series as string | null), + passport_number: decryptField(row.passport_number as string | null), + passport_issued_by: decryptField(row.passport_issued_by as string | null), + passport_issue_date:decryptField(row.passport_issue_date as string | null), + birth_date: decryptField(row.birth_date as string | null), + } +} + const bookingGuests: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) @@ -40,7 +53,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { ORDER BY bg.is_main DESC, bg.is_child ASC, bg.created_at ASC`, [bookingId, hotelId], ) - return rows + return rows.map(decryptBg) }, ) @@ -56,7 +69,6 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - // Verify booking belongs to hotel const { rows: [bk] } = await db.query( 'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2', [bookingId, hotelId], @@ -66,14 +78,15 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { const b = request.body let guestId: string | null = null - // Auto-link or create guest profile if passport provided + // Ищем/создаём гостя по паспортному токену if (b.passport_series && b.passport_number) { const series = b.passport_series.trim() const number = b.passport_number.trim() + const token = passportToken(series + number) const { rows: existing } = await db.query( - `SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`, - [hotelId, series, number], + `SELECT id FROM guests WHERE hotel_id = $1 AND passport_search_token = $2`, + [hotelId, token], ) if (existing.length > 0) { @@ -86,25 +99,30 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { } else { const { rows: [ng] } = await db.query( `INSERT INTO guests - (hotel_id, first_name, last_name, passport_series, passport_number, birth_date, nationality, notes, tags, rating) - VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{}', 3) + (hotel_id, first_name, last_name, passport_series, passport_number, + passport_search_token, birth_date, nationality, notes, tags, rating) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', '{}', 3) RETURNING id`, - [hotelId, b.first_name, b.last_name, series, number, b.birth_date ?? null, b.nationality ?? null], + [ + hotelId, b.first_name, b.last_name, + encryptField(series), encryptField(number), + token, + encryptField(b.birth_date ?? null), + b.nationality ?? null, + ], ) guestId = ng.id } } else if (!b.is_child) { - // Create a minimal guest profile for adults even without passport const { rows: [ng] } = await db.query( `INSERT INTO guests (hotel_id, first_name, last_name, birth_date, notes, tags, rating) VALUES ($1, $2, $3, $4, '', '{}', 3) RETURNING id`, - [hotelId, b.first_name, b.last_name, b.birth_date ?? null], + [hotelId, b.first_name, b.last_name, encryptField(b.birth_date ?? null)], ) guestId = ng.id } - // If marking as main, clear previous main flag if (b.is_main) { await db.query( `UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2`, @@ -112,24 +130,31 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { ) } + const bgToken = (b.passport_series && b.passport_number) + ? passportToken(b.passport_series.trim() + b.passport_number.trim()) + : null + const { rows: [bg] } = await db.query( `INSERT INTO booking_guests (booking_id, hotel_id, guest_id, first_name, last_name, middle_name, birth_date, is_child, is_main, passport_series, passport_number, - passport_issued_by, passport_issue_date, nationality) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + passport_issued_by, passport_issue_date, nationality, passport_search_token) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [ bookingId, hotelId, guestId, b.first_name, b.last_name, b.middle_name ?? null, - b.birth_date ?? null, b.is_child ?? false, b.is_main ?? false, - b.passport_series ?? null, b.passport_number ?? null, - b.passport_issued_by ?? null, b.passport_issue_date ?? null, + encryptField(b.birth_date ?? null), + b.is_child ?? false, b.is_main ?? false, + encryptField(b.passport_series ?? null), + encryptField(b.passport_number ?? null), + encryptField(b.passport_issued_by ?? null), + encryptField(b.passport_issue_date ?? null), b.nationality ?? null, + bgToken, ], ) - // If main guest, link to booking if (b.is_main && guestId) { await db.query( `UPDATE bookings SET guest_id = $1, updated_at = NOW() WHERE id = $2 AND hotel_id = $3`, @@ -137,7 +162,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { ) } - return reply.code(201).send(bg) + return reply.code(201).send(decryptBg(bg)) }, ) @@ -155,7 +180,6 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { const b = request.body - // If setting is_main, clear other rows first if (b.is_main) { await db.query( `UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2 AND id != $3`, @@ -170,17 +194,29 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { const add = (col: string, val: unknown) => { if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) } } - add('first_name', b.first_name) - add('last_name', b.last_name) + + add('first_name', b.first_name) + add('last_name', b.last_name) add('middle_name', b.middle_name) - add('birth_date', b.birth_date || null) - add('is_child', b.is_child) - add('is_main', b.is_main) - add('passport_series', b.passport_series) - add('passport_number', b.passport_number) - add('passport_issued_by', b.passport_issued_by) - add('passport_issue_date', b.passport_issue_date || null) + add('is_child', b.is_child) + add('is_main', b.is_main) add('nationality', b.nationality) + // Шифруем чувствительные поля + if (b.birth_date !== undefined) add('birth_date', encryptField(b.birth_date || null)) + if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series)) + if (b.passport_number !== undefined) add('passport_number', encryptField(b.passport_number)) + if (b.passport_issued_by !== undefined) add('passport_issued_by', encryptField(b.passport_issued_by)) + if (b.passport_issue_date !== undefined)add('passport_issue_date', encryptField(b.passport_issue_date || null)) + // Обновляем токен если поменялся паспорт + if (b.passport_series !== undefined || b.passport_number !== undefined) { + const { rows: [cur] } = await db.query( + 'SELECT passport_series, passport_number FROM booking_guests WHERE id = $1', + [id], + ) + const series = b.passport_series ?? decryptField(cur?.passport_series) ?? '' + const number = b.passport_number ?? decryptField(cur?.passport_number) ?? '' + if (series && number) add('passport_search_token', passportToken(series.trim() + number.trim())) + } if (sets.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) sets.push('updated_at = NOW()') @@ -193,7 +229,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { ) if (!bg) return reply.code(404).send({ error: 'Not found' }) - // Sync subset of fields to guest profile + // Синхронизируем часть полей в профиль гостя if (bg.guest_id) { const gSets: string[] = [] const gVals: unknown[] = [bg.guest_id, hotelId] @@ -202,11 +238,17 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { if (val !== undefined) { gSets.push(`${col} = $${gi++}`); gVals.push(val) } } addG('first_name', b.first_name) - addG('last_name', b.last_name) - addG('passport_series', b.passport_series) - addG('passport_number', b.passport_number) - addG('birth_date', b.birth_date) + addG('last_name', b.last_name) + if (b.passport_series !== undefined) addG('passport_series', encryptField(b.passport_series)) + if (b.passport_number !== undefined) addG('passport_number', encryptField(b.passport_number)) + if (b.birth_date !== undefined) addG('birth_date', encryptField(b.birth_date || null)) addG('nationality', b.nationality) + // Обновляем токен в профиле гостя + if (b.passport_series !== undefined || b.passport_number !== undefined) { + const series = b.passport_series ?? decryptField(bg.passport_series as string) ?? '' + const number = b.passport_number ?? decryptField(bg.passport_number as string) ?? '' + if (series && number) addG('passport_search_token', passportToken(series.trim() + number.trim())) + } if (gSets.length > 0) { gSets.push('updated_at = NOW()') await db.query( @@ -216,7 +258,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => { } } - return bg + return decryptBg(bg) }, ) diff --git a/backend/src/routes/guests.ts b/backend/src/routes/guests.ts index 21904c3..167b1a5 100644 --- a/backend/src/routes/guests.ts +++ b/backend/src/routes/guests.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { encryptField, decryptField, passportToken } from '../lib/crypto' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } @@ -23,6 +24,17 @@ const STATS_JOIN = ` GROUP BY guest_id ) stats ON stats.guest_id = g.id` +// Расшифровывает чувствительные поля строки гостя перед отправкой клиенту +function decryptGuest(row: Record) { + return { + ...row, + passport: decryptField(row.passport as string | null), + passport_series: decryptField(row.passport_series as string | null), + passport_number: decryptField(row.passport_number as string | null), + birth_date: decryptField(row.birth_date as string | null), + } +} + const guests: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) @@ -33,7 +45,8 @@ const guests: FastifyPluginAsync = async (fastify) => { role === 'super_admin' || userSlug === slug // ── GET /api/hotels/:slug/guests ──────────────────────────────────────────── - fastify.get( + // ?q=<имя/email/телефон> или ?passport=<серия+номер> + fastify.get( '/api/hotels/:slug/guests', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -44,16 +57,19 @@ const guests: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { q } = request.query as { q?: string } + const { q, passport: passportQ } = request.query as { q?: string; passport?: string } const params: unknown[] = [hotelId] - let where = 'WHERE g.hotel_id = $1' - if (q) { + + if (passportQ) { + // Точный поиск по паспорту через HMAC-токен (зашифрованные поля не ищутся через LIKE) + params.push(passportToken(passportQ)) + where += ` AND g.passport_search_token = $2` + } else if (q) { params.push(`%${q.toLowerCase()}%`) where += ` AND ( LOWER(g.first_name) LIKE $2 OR LOWER(g.last_name) LIKE $2 OR - LOWER(COALESCE(g.email,'')) LIKE $2 OR COALESCE(g.phone,'') LIKE $2 OR - COALESCE(g.passport_series,'') LIKE $2 OR COALESCE(g.passport_number,'') LIKE $2 + LOWER(COALESCE(g.email,'')) LIKE $2 OR COALESCE(g.phone,'') LIKE $2 )` } @@ -67,7 +83,7 @@ const guests: FastifyPluginAsync = async (fastify) => { ORDER BY g.last_name, g.first_name`, params, ) - return rows + return rows.map(decryptGuest) }, ) @@ -104,12 +120,11 @@ const guests: FastifyPluginAsync = async (fastify) => { ORDER BY b.check_in DESC`, [id, hotelId], ) - return { ...guest, history } + return { ...decryptGuest(guest), history } }, ) // ── POST /api/hotels/:slug/guests ──────────────────────────────────────────── - // Creates a new guest or returns existing one if passport matches fastify.post { const b = request.body - // If passport provided — try to find existing guest + // Если передан паспорт — ищем существующего гостя по HMAC-токену if (b.passport_series && b.passport_number) { + const token = passportToken(b.passport_series.trim() + b.passport_number.trim()) const { rows: existing } = await db.query( - `SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`, - [hotelId, b.passport_series.trim(), b.passport_number.trim()], + `SELECT id FROM guests WHERE hotel_id = $1 AND passport_search_token = $2`, + [hotelId, token], ) if (existing.length > 0) { const { rows: [updated] } = await db.query( @@ -146,28 +162,39 @@ const guests: FastifyPluginAsync = async (fastify) => { updated_at = NOW() WHERE id = $1 AND hotel_id = $2 RETURNING *`, - [existing[0].id, hotelId, b.first_name || null, b.last_name || null, b.middle_name || null, b.email || null, b.phone || null], + [existing[0].id, hotelId, + b.first_name || null, b.last_name || null, b.middle_name || null, + b.email || null, b.phone || null], ) - return reply.code(200).send(updated) + return reply.code(200).send(decryptGuest(updated)) } } + // Вычисляем токен и шифруем поля перед вставкой + const token = (b.passport_series && b.passport_number) + ? passportToken(b.passport_series.trim() + b.passport_number.trim()) + : null + const { rows: [guest] } = await db.query( `INSERT INTO guests (hotel_id, first_name, last_name, middle_name, email, phone, - passport, passport_series, passport_number, + passport, passport_series, passport_number, passport_search_token, birth_date, nationality, gender, city, notes, tags, rating) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *`, [ hotelId, b.first_name, b.last_name, b.middle_name ?? null, b.email ?? null, b.phone ?? null, - b.passport ?? null, b.passport_series ?? null, b.passport_number ?? null, - b.birth_date ?? null, b.nationality ?? null, b.gender ?? null, b.city ?? null, + encryptField(b.passport ?? null), + encryptField(b.passport_series ?? null), + encryptField(b.passport_number ?? null), + token, + encryptField(b.birth_date ?? null), + b.nationality ?? null, b.gender ?? null, b.city ?? null, b.notes ?? '', b.tags ?? [], b.rating ?? 3, ], ) - return reply.code(201).send(guest) + return reply.code(201).send(decryptGuest(guest)) }, ) @@ -197,21 +224,34 @@ const guests: FastifyPluginAsync = async (fastify) => { const add = (col: string, val: unknown) => { if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) } } - add('first_name', b.first_name) - add('last_name', b.last_name) + + add('first_name', b.first_name) + add('last_name', b.last_name) add('middle_name', b.middle_name) - add('email', b.email) - add('phone', b.phone) - add('passport', b.passport) - add('passport_series', b.passport_series) - add('passport_number', b.passport_number) - add('birth_date', b.birth_date || null) + add('email', b.email) + add('phone', b.phone) add('nationality', b.nationality) - add('gender', b.gender) - add('city', b.city) - add('notes', b.notes) - add('tags', b.tags) - add('rating', b.rating) + add('gender', b.gender) + add('city', b.city) + add('notes', b.notes) + add('tags', b.tags) + add('rating', b.rating) + // Шифруем чувствительные поля + if (b.passport !== undefined) add('passport', encryptField(b.passport)) + if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series)) + if (b.passport_number !== undefined) add('passport_number', encryptField(b.passport_number)) + if (b.birth_date !== undefined) add('birth_date', encryptField(b.birth_date || null)) + // Обновляем токен если поменялся паспорт + if (b.passport_series !== undefined || b.passport_number !== undefined) { + // Читаем текущие значения если нужно + const { rows: [cur] } = await db.query( + 'SELECT passport_series, passport_number FROM guests WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + const series = b.passport_series ?? decryptField(cur?.passport_series) ?? '' + const number = b.passport_number ?? decryptField(cur?.passport_number) ?? '' + if (series && number) add('passport_search_token', passportToken(series.trim() + number.trim())) + } if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' }) sets.push('updated_at = NOW()') @@ -221,7 +261,7 @@ const guests: FastifyPluginAsync = async (fastify) => { vals, ) if (!guest) return reply.code(404).send({ error: 'Guest not found' }) - return guest + return decryptGuest(guest) }, ) diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 0425338..e8552c0 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -161,7 +161,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on const query = (series + number).trim() if (query.length < 4 || !slug) return passportTimerRef.current = setTimeout(() => { - api.guests.list(slug, query) + api.guests.list(slug, undefined, query) .then(results => { if (results.length === 1) selectAcGuest(results[0]) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 78bda31..52dc69c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -292,8 +292,12 @@ export const api = { // ── Guests ──────────────────────────────────────────────────────────────── guests: { - list: (slug: string, q?: string) => - req('GET', `/api/hotels/${slug}/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`), + list: (slug: string, q?: string, passport?: string) => { + const params = passport + ? `?passport=${encodeURIComponent(passport)}` + : q ? `?q=${encodeURIComponent(q)}` : '' + return req('GET', `/api/hotels/${slug}/guests${params}`) + }, get: (slug: string, id: string) => req('GET', `/api/hotels/${slug}/guests/${id}`),