feat: add WiFi authorization module
- Backend: hotel_wifi_settings table (migration 046), CRUD routes - Public API endpoint /api/wifi-auth/verify for captive portal - Auth methods: room+lastname, room+birthdate, room+any, room_only - Frontend: WiFiPage with settings, token management, setup guide - Guide tabs: MikroTik, UniFi, Universal with copy-paste instructions - Sidebar: WiFi item in Settings group Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
226
backend/src/routes/wifi-settings.ts
Normal file
226
backend/src/routes/wifi-settings.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { decryptField } from '../lib/crypto'
|
||||
import crypto from 'crypto'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
|
||||
interface WifiSettings {
|
||||
id: string
|
||||
hotelId: string
|
||||
enabled: boolean
|
||||
apiToken: string
|
||||
authMethod: 'room_lastname' | 'room_birthdate' | 'room_any' | 'room_only'
|
||||
ssidName: string | null
|
||||
welcomeText: string | null
|
||||
sessionHours: number
|
||||
}
|
||||
|
||||
const wifiSettings: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// Ensures a wifi settings row exists for this hotel, returns it
|
||||
const ensureSettings = async (hotelId: string) => {
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO hotel_wifi_settings (hotel_id)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET updated_at = hotel_wifi_settings.updated_at
|
||||
RETURNING *`,
|
||||
[hotelId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
// ── GET /api/hotels/:slug/wifi-settings ────────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/wifi-settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (req, reply) => {
|
||||
const { slug } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const row = await ensureSettings(hotelId)
|
||||
return {
|
||||
id: row.id,
|
||||
hotelId: row.hotel_id,
|
||||
enabled: row.enabled,
|
||||
apiToken: row.api_token,
|
||||
authMethod: row.auth_method,
|
||||
ssidName: row.ssid_name,
|
||||
welcomeText: row.welcome_text,
|
||||
sessionHours: row.session_hours,
|
||||
} satisfies WifiSettings
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/wifi-settings ──────────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: Partial<Omit<WifiSettings, 'id' | 'hotelId' | 'apiToken'>> }>(
|
||||
'/api/hotels/:slug/wifi-settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (req, reply) => {
|
||||
const { slug } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
await ensureSettings(hotelId)
|
||||
|
||||
const b = req.body
|
||||
const sets: string[] = []
|
||||
const vals: unknown[] = [hotelId]
|
||||
let idx = 2
|
||||
|
||||
const add = (col: string, val: unknown) => {
|
||||
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
|
||||
}
|
||||
|
||||
add('enabled', b.enabled)
|
||||
add('auth_method', b.authMethod)
|
||||
add('ssid_name', b.ssidName)
|
||||
add('welcome_text', b.welcomeText)
|
||||
add('session_hours', b.sessionHours)
|
||||
|
||||
if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' })
|
||||
sets.push('updated_at = NOW()')
|
||||
|
||||
const { rows: [row] } = await db.query(
|
||||
`UPDATE hotel_wifi_settings SET ${sets.join(', ')} WHERE hotel_id = $1 RETURNING *`,
|
||||
vals,
|
||||
)
|
||||
return {
|
||||
id: row.id,
|
||||
hotelId: row.hotel_id,
|
||||
enabled: row.enabled,
|
||||
apiToken: row.api_token,
|
||||
authMethod: row.auth_method,
|
||||
ssidName: row.ssid_name,
|
||||
welcomeText: row.welcome_text,
|
||||
sessionHours: row.session_hours,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/wifi-settings/regenerate-token ──────────────────
|
||||
fastify.post<SlugParam>(
|
||||
'/api/hotels/:slug/wifi-settings/regenerate-token',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (req, reply) => {
|
||||
const { slug } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
await ensureSettings(hotelId)
|
||||
const newToken = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
const { rows: [row] } = await db.query(
|
||||
`UPDATE hotel_wifi_settings SET api_token = $2, updated_at = NOW()
|
||||
WHERE hotel_id = $1 RETURNING api_token`,
|
||||
[hotelId, newToken],
|
||||
)
|
||||
return { apiToken: row.api_token }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public: POST /api/wifi-auth/verify ─────────────────────────────────────
|
||||
// Called by wifisync.ru captive portal to verify a guest
|
||||
// Auth: Bearer <api_token from hotel_wifi_settings>
|
||||
export const wifiAuthVerify: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.post<{
|
||||
Body: { room_number: string; last_name?: string; birth_date?: string }
|
||||
}>(
|
||||
'/api/wifi-auth/verify',
|
||||
async (req, reply) => {
|
||||
const authHeader = req.headers.authorization ?? ''
|
||||
if (!authHeader.startsWith('Bearer ')) return reply.code(401).send({ ok: false, error: 'Unauthorized' })
|
||||
const token = authHeader.slice(7).trim()
|
||||
|
||||
// Find hotel by token
|
||||
const { rows: [ws] } = await db.query(
|
||||
`SELECT hotel_id, auth_method, session_hours, enabled
|
||||
FROM hotel_wifi_settings WHERE api_token = $1`,
|
||||
[token],
|
||||
)
|
||||
if (!ws) return reply.code(401).send({ ok: false, error: 'Invalid token' })
|
||||
if (!ws.enabled) return reply.code(403).send({ ok: false, error: 'WiFi auth is disabled' })
|
||||
|
||||
const { room_number, last_name, birth_date } = req.body
|
||||
if (!room_number?.trim()) return reply.code(400).send({ ok: false, error: 'room_number required' })
|
||||
|
||||
// Find active booking in this room
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { rows: bookings } = await db.query(
|
||||
`SELECT b.id, b.guest_id, b.check_out,
|
||||
g.first_name, g.last_name AS last_name_enc, g.birth_date AS birth_date_enc
|
||||
FROM bookings b
|
||||
JOIN rooms r ON r.id = b.room_id
|
||||
LEFT JOIN guests g ON g.id = b.guest_id
|
||||
WHERE b.hotel_id = $1
|
||||
AND r.number = $2
|
||||
AND b.status IN ('confirmed', 'checked_in')
|
||||
AND b.check_in <= $3
|
||||
AND b.check_out >= $3`,
|
||||
[ws.hotel_id, room_number.trim(), today],
|
||||
)
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return { ok: false, error: 'Бронирование не найдено. Обратитесь на ресепшн.' }
|
||||
}
|
||||
|
||||
const method: string = ws.auth_method
|
||||
|
||||
// room_only — no extra check needed
|
||||
if (method === 'room_only') {
|
||||
const b = bookings[0]
|
||||
return {
|
||||
ok: true,
|
||||
guestName: [b.first_name, decryptField(b.last_name_enc)].filter(Boolean).join(' ') || null,
|
||||
checkout: b.check_out,
|
||||
sessionHours: ws.session_hours,
|
||||
}
|
||||
}
|
||||
|
||||
// Check credentials against all bookings in that room
|
||||
for (const b of bookings) {
|
||||
const dbLastName = (decryptField(b.last_name_enc) ?? '').toLowerCase().trim()
|
||||
const dbBirthDate = decryptField(b.birth_date_enc) ?? '' // YYYY-MM-DD
|
||||
|
||||
const lastNameMatch = last_name && dbLastName && last_name.toLowerCase().trim() === dbLastName
|
||||
const birthDateMatch = birth_date && dbBirthDate && birth_date === dbBirthDate
|
||||
|
||||
const matches =
|
||||
(method === 'room_lastname' && lastNameMatch) ||
|
||||
(method === 'room_birthdate' && birthDateMatch) ||
|
||||
(method === 'room_any' && (lastNameMatch || birthDateMatch))
|
||||
|
||||
if (matches) {
|
||||
return {
|
||||
ok: true,
|
||||
guestName: [b.first_name, decryptField(b.last_name_enc)].filter(Boolean).join(' ') || null,
|
||||
checkout: b.check_out,
|
||||
sessionHours: ws.session_hours,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: 'Неверные данные. Проверьте фамилию или дату рождения.' }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default wifiSettings
|
||||
Reference in New Issue
Block a user