Add AES-256-GCM encryption for sensitive personal data fields
- New backend/src/lib/crypto.ts: encrypt/decrypt (AES-256-GCM) + passportToken (HMAC-SHA256) - Migration 012: birth_date/passport_issue_date → TEXT, add passport_search_token indexes - guests.ts: encrypt passport_series/number/birth_date on write, decrypt on read, passport search via HMAC token (?passport= param) instead of plaintext LIKE - booking-guests.ts: encrypt all sensitive fields, search token for passport lookup - api.ts: guests.list() supports passport= param for exact passport lookup - BookingDetailPanel: use passport param for passport-based autocomplete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>) {
|
||||
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<string | null> => {
|
||||
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<SlugParam & { Querystring: { q?: string } }>(
|
||||
// ?q=<имя/email/телефон> или ?passport=<серия+номер>
|
||||
fastify.get<SlugParam & { Querystring: { q?: string; passport?: string } }>(
|
||||
'/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<SlugParam & { Body: {
|
||||
first_name: string; last_name: string; middle_name?: string
|
||||
email?: string; phone?: string
|
||||
@@ -129,11 +144,12 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user