222 lines
8.7 KiB
JavaScript
222 lines
8.7 KiB
JavaScript
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
|
import {
|
|
comparePassword,
|
|
hashPassword,
|
|
isAdminEmail,
|
|
issueEmailCode,
|
|
normalizeEmail,
|
|
validatePassword,
|
|
verifyEmailCode,
|
|
} from '../lib/auth.js'
|
|
import { generateAvatar } from '../lib/generate-avatar.js'
|
|
import { prisma } from '../lib/prisma.js'
|
|
import { checkLoginRateLimit } from '../lib/rate-limit.js'
|
|
|
|
export function mapUserForClient(user) {
|
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
|
const userEmail = normalizeEmail(user.email)
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
displayName: user.displayName,
|
|
avatar: user.avatar,
|
|
avatarStyle: user.avatarStyle,
|
|
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
|
}
|
|
}
|
|
|
|
export async function registerAuthRoutes(fastify) {
|
|
fastify.post('/api/auth/request-code', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
|
|
const code = await issueEmailCode({ email, purpose: 'login' })
|
|
|
|
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
|
const isAdmin = email === adminEmail
|
|
|
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
|
|
email,
|
|
code,
|
|
isAdmin,
|
|
})
|
|
|
|
return { ok: true }
|
|
})
|
|
|
|
fastify.post('/api/auth/verify-code', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
const code = String(request.body?.code || '').trim()
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
|
|
|
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
|
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
|
|
|
const avatarUri = await generateAvatar(email)
|
|
const user = await prisma.user.upsert({
|
|
where: { email },
|
|
update: {},
|
|
create: { email, avatar: avatarUri, avatarStyle: 'avataaars' },
|
|
})
|
|
|
|
// Ensure notification preference exists
|
|
await prisma.notificationPreference.upsert({
|
|
where: { userId: user.id },
|
|
create: { userId: user.id, globalEnabled: true },
|
|
update: {},
|
|
})
|
|
|
|
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
|
return { token, user: mapUserForClient(user) }
|
|
})
|
|
|
|
fastify.post('/api/auth/register', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
const password = String(request.body?.password || '')
|
|
const displayNameRaw = request.body?.displayName
|
|
const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0]
|
|
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' })
|
|
|
|
const passwordErr = validatePassword(password)
|
|
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
|
|
|
const exists = await prisma.user.findUnique({ where: { email } })
|
|
if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' })
|
|
|
|
const passwordHash = await hashPassword(password)
|
|
const avatarUri = await generateAvatar(email)
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash,
|
|
displayName: displayName || null,
|
|
avatar: avatarUri,
|
|
avatarStyle: 'avataaars',
|
|
},
|
|
})
|
|
|
|
await prisma.notificationPreference.upsert({
|
|
where: { userId: user.id },
|
|
create: { userId: user.id, globalEnabled: true },
|
|
update: {},
|
|
})
|
|
|
|
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
|
return reply.code(201).send({ token, user: mapUserForClient(user) })
|
|
})
|
|
|
|
fastify.post('/api/auth/login', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
const password = String(request.body?.password || '')
|
|
const ip = request.ip
|
|
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' })
|
|
|
|
const rate = checkLoginRateLimit(ip)
|
|
if (!rate.allowed) {
|
|
return reply
|
|
.code(429)
|
|
.header('Retry-After', String(rate.retryAfter))
|
|
.send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` })
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { email } })
|
|
if (!user || !user.passwordHash) {
|
|
return reply.code(401).send({ error: 'Неверная почта или пароль' })
|
|
}
|
|
|
|
const valid = await comparePassword(password, user.passwordHash)
|
|
if (!valid) {
|
|
return reply.code(401).send({ error: 'Неверная почта или пароль' })
|
|
}
|
|
|
|
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
|
return { token, user: mapUserForClient(user) }
|
|
})
|
|
|
|
fastify.post('/api/auth/forgot-password', async (request) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
if (!email || !email.includes('@')) return { ok: true }
|
|
|
|
if (isAdminEmail(email)) return { ok: true }
|
|
|
|
const user = await prisma.user.findUnique({ where: { email } })
|
|
if (!user || !user.passwordHash) return { ok: true }
|
|
|
|
await issueEmailCode({ email, purpose: 'reset_password' })
|
|
return { ok: true }
|
|
})
|
|
|
|
fastify.post('/api/auth/reset-password', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
const code = String(request.body?.code || '').trim()
|
|
const newPassword = String(request.body?.newPassword || '')
|
|
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
|
|
|
const ok = await verifyEmailCode({ email, purpose: 'reset_password', code })
|
|
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
|
|
|
const passwordErr = validatePassword(newPassword)
|
|
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
|
|
|
const passwordHash = await hashPassword(newPassword)
|
|
await prisma.user.update({ where: { email }, data: { passwordHash } })
|
|
|
|
return { ok: true }
|
|
})
|
|
|
|
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
|
const userId = request.user.sub
|
|
const nameRaw = request.body?.displayName
|
|
const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
|
const avatarRaw = request.body?.avatar
|
|
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
|
|
const avatarStyleRaw = request.body?.avatarStyle
|
|
const avatarStyle =
|
|
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
|
|
|
|
if (displayName !== null && displayName.length > 40)
|
|
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
|
if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
|
|
if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) {
|
|
return reply.code(400).send({ error: 'Стиль аватара слишком длинный' })
|
|
}
|
|
|
|
const data = {
|
|
displayName: displayName && displayName.length ? displayName : null,
|
|
}
|
|
|
|
if (avatar !== undefined) {
|
|
data.avatar = avatar === '' ? null : avatar
|
|
}
|
|
if (avatarStyle !== undefined) {
|
|
data.avatarStyle = avatarStyle === '' ? null : avatarStyle
|
|
}
|
|
const updated = await prisma.user.update({
|
|
where: { id: userId },
|
|
data,
|
|
})
|
|
return { user: mapUserForClient(updated) }
|
|
})
|
|
|
|
fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
|
const userId = request.user.sub
|
|
|
|
const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP']
|
|
|
|
const activeOrders = await prisma.order.findMany({
|
|
where: { userId, status: { in: ACTIVE_STATUSES } },
|
|
select: { id: true },
|
|
})
|
|
|
|
await prisma.user.delete({ where: { id: userId } })
|
|
return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) }
|
|
})
|
|
}
|