base commit

This commit is contained in:
@kirill.komarov
2026-04-28 21:36:30 +05:00
parent 55480d4aa5
commit 2148fd7a12
24 changed files with 1578 additions and 121 deletions
+15
View File
@@ -1,8 +1,10 @@
import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js'
const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '')
@@ -17,7 +19,20 @@ await fastify.register(cors, {
credentials: true,
})
await fastify.register(jwt, {
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
})
fastify.decorate('authenticate', async function authenticate(request, reply) {
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: 'Не авторизован' })
}
})
registerAuth(fastify)
await registerAuthRoutes(fastify)
await registerApiRoutes(fastify)
fastify.get('/health', async () => ({ ok: true }))
+64
View File
@@ -0,0 +1,64 @@
import crypto from 'node:crypto'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma.js'
import { sendLoginCodeEmail } from './email.js'
export function normalizeEmail(email) {
return String(email || '').trim().toLowerCase()
}
export function randomCode6() {
return String(Math.floor(100000 + Math.random() * 900000))
}
export function sha256(input) {
return crypto.createHash('sha256').update(input).digest('hex')
}
export async function issueEmailCode({ email, purpose, userId = null }) {
const code = randomCode6()
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
await prisma.authCode.create({
data: {
email,
purpose,
userId,
codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`),
expiresAt,
},
})
await sendLoginCodeEmail({ to: email, code })
}
export async function verifyEmailCode({ email, purpose, code, userId = null }) {
const now = new Date()
const codeHash = sha256(`${email}:${purpose}:${code}:${userId ?? ''}`)
const found = await prisma.authCode.findFirst({
where: {
email,
purpose,
userId,
codeHash,
usedAt: null,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
})
if (!found) return false
await prisma.authCode.update({
where: { id: found.id },
data: { usedAt: now },
})
return true
}
export async function hashPassword(password) {
return bcrypt.hash(password, 10)
}
export async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash)
}
+33
View File
@@ -0,0 +1,33 @@
import nodemailer from 'nodemailer'
function hasSmtpEnv() {
return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS)
}
export async function sendLoginCodeEmail({ to, code }) {
if (!hasSmtpEnv()) {
// dev fallback
console.log(`[DEV] login code for ${to}: ${code}`)
return
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
const from = process.env.MAIL_FROM || process.env.SMTP_USER
await transporter.sendMail({
from,
to,
subject: 'Код входа',
text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`,
})
}
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from '../lib/prisma.js'
import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js'
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: 'Некорректная почта' })
// purpose: login (включает и регистрацию — пользователь создастся при verify)
await issueEmailCode({ email, purpose: 'login' })
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 },
})
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: { id: user.id, email: user.email, name: user.name } }
})
fastify.post('/api/auth/register', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const password = String(request.body?.password || '')
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' })
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' })
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({ data: { email, passwordHash } })
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name } })
})
fastify.post('/api/auth/login', async (request, reply) => {
const email = normalizeEmail(request.body?.email)
const password = String(request.body?.password || '')
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!password) return reply.code(400).send({ error: 'Укажите пароль' })
const user = await prisma.user.findUnique({ where: { email } })
if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' })
const ok = await verifyPassword(password, user.passwordHash)
if (!ok) return reply.code(401).send({ error: 'Неверные данные' })
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: { id: user.id, email: user.email, name: user.name } }
})
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: { id: user.id, email: user.email, name: user.name } }
},
)
fastify.post(
'/api/me/change-email/request-code',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const newEmail = normalizeEmail(request.body?.newEmail)
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
await issueEmailCode({ email: newEmail, purpose: 'change_email', userId })
return { ok: true }
},
)
fastify.post(
'/api/me/change-email/verify',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const newEmail = normalizeEmail(request.body?.newEmail)
const code = String(request.body?.code || '').trim()
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId })
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
const user = await prisma.user.update({
where: { id: userId },
data: { email: newEmail },
})
return { user: { id: user.id, email: user.email, name: user.name } }
},
)
fastify.patch(
'/api/me/password',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : ''
const newPassword = String(request.body?.newPassword || '')
if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' })
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
if (user.passwordHash) {
if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' })
const ok = await verifyPassword(currentPassword, user.passwordHash)
if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' })
}
const passwordHash = await hashPassword(newPassword)
const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
return { user: { id: updated.id, email: updated.email, name: updated.name } }
},
)
fastify.patch(
'/api/me/profile',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const nameRaw = request.body?.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
const updated = await prisma.user.update({
where: { id: userId },
data: { name: name && name.length ? name : null },
})
return { user: { id: updated.id, email: updated.email, name: updated.name } }
},
)
}