From c3e4f5bdd2bcd2bbc9ad356f53bf4762d5c75ed9 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:26:00 +0500 Subject: [PATCH] feat(server): add POST /api/auth/register and /api/auth/login - Add register endpoint with email/password validation, bcrypt hashing - Add login endpoint with rate limiting per IP (5 attempts/min) - Add helper functions: validatePassword, hashPassword, comparePassword, isAdminEmail - Add checkLoginRateLimit for brute-force protection - Add bcrypt dependency - Remove avatarType column from User (migration) --- server/package.json | 1 + .../migration.sql | 28 +++++++ server/prisma/schema.prisma | 1 - server/src/lib/auth.js | 32 ++++++++ server/src/lib/rate-limit.js | 19 +++++ server/src/routes/auth.js | 77 ++++++++++++++++++- 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql create mode 100644 server/src/lib/rate-limit.js diff --git a/server/package.json b/server/package.json index 37080f8..ed46702 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7", diff --git a/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql new file mode 100644 index 0000000..b9ee70a --- /dev/null +++ b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the column `avatarType` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "displayName" TEXT, + "firstName" TEXT, + "lastName" TEXT, + "gender" TEXT, + "avatar" TEXT, + "avatarStyle" TEXT, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3bc4946..be0aa90 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -82,7 +82,6 @@ model User { lastName String? gender String? avatar String? - avatarType String? avatarStyle String? passwordHash String? createdAt DateTime @default(now()) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index fcd6996..cdfc5bb 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -1,4 +1,5 @@ import crypto from 'node:crypto' +import bcrypt from 'bcrypt' import { sendLoginCodeEmail } from './email.js' import { prisma } from './prisma.js' @@ -72,3 +73,34 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) { }) return true } + +const PASSWORD_MIN_LEN = 8 + +const PASSWORD_REGEX = { + letter: /[a-zа-яё]/i, + digit: /[0-9]/, + special: /[^a-zа-яё0-9\s]/i, +} + +export function validatePassword(password) { + if (typeof password !== 'string') return 'Пароль обязателен' + if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` + if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' + if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' + if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' + return null +} + +export async function hashPassword(password) { + return bcrypt.hash(password, 10) +} + +export async function comparePassword(password, hash) { + return bcrypt.compare(password, hash) +} + +export function isAdminEmail(email) { + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + if (!adminEmail) return false + return normalizeEmail(email) === adminEmail +} diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js new file mode 100644 index 0000000..e37a9f0 --- /dev/null +++ b/server/src/lib/rate-limit.js @@ -0,0 +1,19 @@ +const windows = new Map() + +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +export function checkLoginRateLimit(ip) { + const now = Date.now() + const entry = windows.get(ip) + if (!entry || now - entry.start > WINDOW_MS) { + windows.set(ip, { start: now, count: 1 }) + return { allowed: true } + } + entry.count += 1 + if (entry.count > MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + return { allowed: false, retryAfter } + } + return { allowed: true } +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index de8773f..ce870a8 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,6 +1,15 @@ import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' -import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.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) @@ -64,6 +73,72 @@ export async function registerAuthRoutes(fastify) { 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.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } })