- Reject pure-numeric passwords (must contain ≥1 letter) in RegisterForm, ResetPasswordPage, and backend register/reset-password handlers - Replace bare strength bars with labeled 2×2 grid: ≥8 символов, Содержит букву, Заглавная буква, Цифра - Auto-capitalize first letter of each word in "Название отеля" and "Контактное лицо" fields (autoCapitalize="words" + JS handler) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
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 { sendConfirmationEmail, sendPasswordResetEmail } from '../email'
|
||
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 или пароль' })
|
||
}
|
||
|
||
if (!user.email_confirmed) {
|
||
return reply.code(403).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,
|
||
})
|
||
|
||
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/register ────────────────────────────────────────────────
|
||
fastify.post<{
|
||
Body: {
|
||
hotelName: string
|
||
address?: string
|
||
contact: string
|
||
email: string
|
||
phone?: string
|
||
password: string
|
||
}
|
||
}>(
|
||
'/api/auth/register',
|
||
{
|
||
schema: {
|
||
body: {
|
||
type: 'object',
|
||
required: ['hotelName', 'contact', 'email', 'password'],
|
||
properties: {
|
||
hotelName: { type: 'string', minLength: 2 },
|
||
address: { type: 'string' },
|
||
contact: { type: 'string', minLength: 2 },
|
||
email: { type: 'string' },
|
||
phone: { type: 'string' },
|
||
password: { type: 'string', minLength: 8 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
async (request, reply) => {
|
||
const { hotelName, address, contact, email, phone, password } = request.body
|
||
|
||
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) {
|
||
return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' })
|
||
}
|
||
|
||
const { rows: existing } = await db.query(
|
||
'SELECT id FROM users WHERE email = $1',
|
||
[email.toLowerCase().trim()],
|
||
)
|
||
if (existing.length > 0) {
|
||
return reply.code(409).send({ error: 'Пользователь с таким email уже существует' })
|
||
}
|
||
|
||
// Generate slug from hotel name (transliterate RU→EN)
|
||
const ru: Record<string, string> = {
|
||
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',
|
||
к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',
|
||
х:'h',ц:'ts',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya',
|
||
}
|
||
const baseSlug = hotelName
|
||
.toLowerCase()
|
||
.split('')
|
||
.map(c => ru[c] ?? c)
|
||
.join('')
|
||
.replace(/[^a-z0-9\s-]/g, '')
|
||
.trim()
|
||
.replace(/\s+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.substring(0, 50) || 'hotel'
|
||
|
||
let slug = baseSlug
|
||
let suffix = 2
|
||
for (;;) {
|
||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||
if (rows.length === 0) break
|
||
slug = `${baseSlug}-${suffix++}`
|
||
}
|
||
|
||
const passwordHash = await bcrypt.hash(password, 12)
|
||
const confirmToken = crypto.randomBytes(32).toString('hex')
|
||
|
||
const client = await db.connect()
|
||
try {
|
||
await client.query('BEGIN')
|
||
const { rows: [hotel] } = await client.query(
|
||
`INSERT INTO hotels (name, slug, address, timezone, currency, plan, is_active)
|
||
VALUES ($1, $2, $3, 'Europe/Moscow', 'RUB', 'starter', true)
|
||
RETURNING id`,
|
||
[hotelName, slug, address ?? null],
|
||
)
|
||
await client.query(
|
||
`INSERT INTO users (name, email, password_hash, role, hotel_id, phone, email_confirmed, confirmation_token, confirmation_sent_at)
|
||
VALUES ($1, $2, $3, 'manager', $4, $5, false, $6, NOW())`,
|
||
[contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken],
|
||
)
|
||
await client.query('COMMIT')
|
||
} catch (err) {
|
||
await client.query('ROLLBACK')
|
||
throw err
|
||
} finally {
|
||
client.release()
|
||
}
|
||
|
||
try {
|
||
await sendConfirmationEmail(email, contact, confirmToken)
|
||
} catch (emailErr) {
|
||
console.error('[email] Confirmation email failed:', emailErr)
|
||
// Don't fail the registration — user can request resend later
|
||
}
|
||
|
||
return reply.code(201).send({
|
||
ok: true,
|
||
message: `Письмо с подтверждением отправлено на ${email}`,
|
||
})
|
||
},
|
||
)
|
||
|
||
// ── GET /api/auth/confirm-email ────────────────────────────────────────────
|
||
fastify.get<{ Querystring: { token?: string } }>(
|
||
'/api/auth/confirm-email',
|
||
async (request, reply) => {
|
||
const { token } = request.query
|
||
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
|
||
|
||
if (!token) {
|
||
return reply.redirect(`${appUrl}/login?error=invalid_token`)
|
||
}
|
||
|
||
const { rows } = await db.query(
|
||
`SELECT id, confirmation_sent_at FROM users
|
||
WHERE confirmation_token = $1 AND email_confirmed = false`,
|
||
[token],
|
||
)
|
||
|
||
if (rows.length === 0) {
|
||
return reply.redirect(`${appUrl}/login?error=invalid_token`)
|
||
}
|
||
|
||
const sentAt = new Date(rows[0].confirmation_sent_at as string)
|
||
const hoursElapsed = (Date.now() - sentAt.getTime()) / 1000 / 3600
|
||
if (hoursElapsed > 24) {
|
||
return reply.redirect(`${appUrl}/login?error=token_expired`)
|
||
}
|
||
|
||
await db.query(
|
||
`UPDATE users SET email_confirmed = true, confirmation_token = NULL WHERE id = $1`,
|
||
[rows[0].id],
|
||
)
|
||
|
||
return reply.redirect(`${appUrl}/login?confirmed=1`)
|
||
},
|
||
)
|
||
|
||
// ── POST /api/auth/resend-confirmation ────────────────────────────────────
|
||
fastify.post<{ Body: { email: string } }>(
|
||
'/api/auth/resend-confirmation',
|
||
{
|
||
schema: {
|
||
body: {
|
||
type: 'object',
|
||
required: ['email'],
|
||
properties: { email: { type: 'string' } },
|
||
},
|
||
},
|
||
},
|
||
async (request, reply) => {
|
||
const { email } = request.body
|
||
const { rows } = await db.query(
|
||
`SELECT id, name FROM users WHERE email = $1 AND email_confirmed = false`,
|
||
[email.toLowerCase().trim()],
|
||
)
|
||
// Always return 200 to prevent enumeration
|
||
if (rows.length === 0) return reply.code(200).send({ ok: true })
|
||
|
||
const user = rows[0]
|
||
const confirmToken = crypto.randomBytes(32).toString('hex')
|
||
await db.query(
|
||
`UPDATE users SET confirmation_token = $1, confirmation_sent_at = NOW() WHERE id = $2`,
|
||
[confirmToken, user.id],
|
||
)
|
||
|
||
try {
|
||
await sendConfirmationEmail(email, user.name as string, confirmToken)
|
||
} catch (err) {
|
||
console.error('[email] Resend confirmation failed:', err)
|
||
}
|
||
|
||
return reply.code(200).send({ ok: true })
|
||
},
|
||
)
|
||
|
||
// ── 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 }
|
||
})
|
||
|
||
// ── POST /api/auth/forgot-password ────────────────────────────────────────
|
||
fastify.post<{ Body: { email: string } }>(
|
||
'/api/auth/forgot-password',
|
||
{
|
||
schema: {
|
||
body: {
|
||
type: 'object',
|
||
required: ['email'],
|
||
properties: { email: { type: 'string' } },
|
||
},
|
||
},
|
||
},
|
||
async (request, reply) => {
|
||
const { email } = request.body
|
||
const { rows } = await db.query(
|
||
`SELECT id, name FROM users WHERE email = $1 AND email_confirmed = true`,
|
||
[email.toLowerCase().trim()],
|
||
)
|
||
// Always return 200 to prevent email enumeration
|
||
if (rows.length === 0) return { ok: true }
|
||
|
||
const user = rows[0]
|
||
const resetToken = crypto.randomBytes(32).toString('hex')
|
||
const expires = new Date(Date.now() + 3600 * 1000) // 1 hour
|
||
|
||
await db.query(
|
||
`UPDATE users SET reset_token = $1, reset_token_expires = $2 WHERE id = $3`,
|
||
[resetToken, expires, user.id],
|
||
)
|
||
|
||
try {
|
||
await sendPasswordResetEmail(email, user.name as string, resetToken)
|
||
} catch (err) {
|
||
console.error('[email] Password reset email failed:', err)
|
||
}
|
||
|
||
return reply.code(200).send({ ok: true })
|
||
},
|
||
)
|
||
|
||
// ── POST /api/auth/reset-password ─────────────────────────────────────────
|
||
fastify.post<{ Body: { token: string; password: string } }>(
|
||
'/api/auth/reset-password',
|
||
{
|
||
schema: {
|
||
body: {
|
||
type: 'object',
|
||
required: ['token', 'password'],
|
||
properties: {
|
||
token: { type: 'string' },
|
||
password: { type: 'string', minLength: 8 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
async (request, reply) => {
|
||
const { token, password } = request.body
|
||
|
||
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) {
|
||
return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' })
|
||
}
|
||
|
||
const { rows } = await db.query(
|
||
`SELECT id FROM users
|
||
WHERE reset_token = $1
|
||
AND reset_token_expires > NOW()`,
|
||
[token],
|
||
)
|
||
|
||
if (rows.length === 0) {
|
||
return reply.code(400).send({ error: 'Ссылка недействительна или истекла' })
|
||
}
|
||
|
||
const passwordHash = await bcrypt.hash(password, 12)
|
||
await db.query(
|
||
`UPDATE users SET password_hash = $1, reset_token = NULL, reset_token_expires = NULL WHERE id = $2`,
|
||
[passwordHash, rows[0].id],
|
||
)
|
||
|
||
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
|