import { FastifyPluginAsync } from 'fastify' import bcrypt from 'bcryptjs' import crypto from 'crypto' import { db } from '../db' import { redis } from '../redis' import { config } from '../config' import type { JwtPayload } from '../types' const auth: FastifyPluginAsync = async (fastify) => { // ── POST /api/auth/login ─────────────────────────────────────────────────── fastify.post<{ Body: { email: string; password: string } }>( '/api/auth/login', { schema: { body: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string' }, password: { type: 'string' }, }, }, }, }, async (request, reply) => { const { email, password } = request.body const { rows } = await db.query( `SELECT u.*, h.slug AS hotel_slug FROM users u LEFT JOIN hotels h ON h.id = u.hotel_id WHERE u.email = $1`, [email.toLowerCase().trim()], ) const user = rows[0] if (!user || !(await bcrypt.compare(password, user.password_hash))) { return reply.code(401).send({ error: 'Неверный email или пароль' }) } const payload: JwtPayload = { sub: user.id, email: user.email, name: user.name, role: user.role, hotelId: user.hotel_id ?? null, hotelSlug: user.hotel_slug ?? null, } const accessToken = fastify.jwt.sign(payload, { expiresIn: config.jwt.accessExpiry, }) // Refresh token — opaque, stored in Redis const refreshToken = crypto.randomBytes(40).toString('hex') await redis.set( `refresh:${refreshToken}`, JSON.stringify(payload), 'EX', config.jwt.refreshExpiry, ) reply.setCookie('refresh_token', refreshToken, { httpOnly: true, secure: config.nodeEnv === 'production', sameSite: 'strict', path: '/api/auth', maxAge: config.jwt.refreshExpiry, }) return { access_token: accessToken, user: { id: user.id, email: user.email, name: user.name, role: user.role, hotelId: user.hotel_id ?? null, hotelSlug: user.hotel_slug ?? null, }, } }, ) // ── POST /api/auth/refresh ───────────────────────────────────────────────── fastify.post('/api/auth/refresh', async (request, reply) => { const refreshToken = request.cookies?.refresh_token if (!refreshToken) return reply.code(401).send({ error: 'No refresh token' }) const stored = await redis.get(`refresh:${refreshToken}`) if (!stored) return reply.code(401).send({ error: 'Invalid or expired refresh token' }) const payload = JSON.parse(stored) as JwtPayload const accessToken = fastify.jwt.sign(payload, { expiresIn: config.jwt.accessExpiry, }) return { access_token: accessToken } }) // ── POST /api/auth/logout ────────────────────────────────────────────────── fastify.post('/api/auth/logout', async (request, reply) => { const refreshToken = request.cookies?.refresh_token if (refreshToken) { await redis.del(`refresh:${refreshToken}`) } reply.clearCookie('refresh_token', { path: '/api/auth' }) return { ok: true } }) // ── GET /api/auth/me ─────────────────────────────────────────────────────── fastify.get( '/api/auth/me', { onRequest: [fastify.authenticate] }, async (request) => { const { rows } = await db.query( `SELECT u.id, u.email, u.name, u.role, u.hotel_id, u.created_at, h.slug AS hotel_slug FROM users u LEFT JOIN hotels h ON h.id = u.hotel_id WHERE u.id = $1`, [request.user.sub], ) return rows[0] ?? null }, ) } export default auth