Files
hotelsync/backend/src/routes/auth.ts
HotelSync e59bc752b0 Add password letter requirement + labeled strength UI + auto-capitalize name fields
- 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>
2026-03-17 19:35:54 +03:00

397 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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