Files
shop-server/server/src/routes/auth.js
T
2026-05-22 14:20:11 +05:00

309 lines
12 KiB
JavaScript

import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import {
comparePassword,
hashPassword,
isAdminEmail,
issueEmailCode,
normalizeEmail,
validatePassword,
verifyEmailCode,
} from '../lib/auth.js'
import { prisma } from '../lib/prisma.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js'
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,
firstName: user.firstName,
lastName: user.lastName,
gender: user.gender,
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 user = await prisma.user.upsert({
where: { email },
update: {},
create: { email },
})
// 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 user = await prisma.user.create({
data: {
email,
passwordHash,
displayName: displayName || null,
avatar: null,
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.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
const userId = request.user.sub
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return { user: null }
return { user: mapUserForClient(user) }
})
fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => {
const userId = request.user.sub
const user = await prisma.user.findUnique({
where: { id: userId },
include: { oauthAccounts: { select: { provider: true } } },
})
if (!user) return { methods: [] }
const providers = user.oauthAccounts.map((a) => a.provider)
return {
methods: [
{ type: 'password', active: Boolean(user.passwordHash) },
{ type: 'vk', active: providers.includes('vk') },
{ type: 'yandex', active: providers.includes('yandex') },
],
}
})
fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
if (isAdminEmail(request.user.email)) {
return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' })
}
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' })
const password = String(request.body?.password || '')
const passwordErr = validatePassword(password)
if (passwordErr) return reply.code(400).send({ error: passwordErr })
const passwordHash = await hashPassword(password)
await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
return { ok: true }
})
fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
if (isAdminEmail(request.user.email)) {
return reply.code(403).send({ error: 'Администратор не может менять пароль' })
}
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
if (!user.passwordHash) return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' })
const oldPassword = String(request.body?.oldPassword || '')
const valid = await comparePassword(oldPassword, user.passwordHash)
if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' })
const newPassword = String(request.body?.newPassword || '')
const passwordErr = validatePassword(newPassword)
if (passwordErr) return reply.code(400).send({ error: passwordErr })
const passwordHash = await hashPassword(newPassword)
await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
return { ok: true }
})
fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
const provider = request.params?.provider
if (isAdminEmail(request.user.email)) {
return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' })
}
if (provider !== 'vk' && provider !== 'yandex') {
return reply.code(400).send({ error: 'Неизвестный провайдер' })
}
const oauth = await prisma.oAuthAccount.findFirst({
where: { userId, provider },
})
if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' })
const remainingOAuth = await prisma.oAuthAccount.count({
where: { userId, provider: { not: provider } },
})
const currentUser = await prisma.user.findUnique({
where: { id: userId },
select: { passwordHash: true },
})
if (!currentUser?.passwordHash && remainingOAuth === 0) {
return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' })
}
await prisma.oAuthAccount.delete({ where: { id: oauth.id } })
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) }
})
}