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)
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
"@prisma/client": "5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -82,7 +82,6 @@ model User {
|
|||||||
lastName String?
|
lastName String?
|
||||||
gender String?
|
gender String?
|
||||||
avatar String?
|
avatar String?
|
||||||
avatarType String?
|
|
||||||
avatarStyle String?
|
avatarStyle String?
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
import { sendLoginCodeEmail } from './email.js'
|
import { sendLoginCodeEmail } from './email.js'
|
||||||
import { prisma } from './prisma.js'
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
@@ -72,3 +73,34 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) {
|
|||||||
})
|
})
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
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 { prisma } from '../lib/prisma.js'
|
||||||
|
import { checkLoginRateLimit } from '../lib/rate-limit.js'
|
||||||
|
|
||||||
function mapUserForClient(user) {
|
function mapUserForClient(user) {
|
||||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||||
@@ -64,6 +73,72 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
return { token, user: mapUserForClient(user) }
|
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) => {
|
fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
|||||||
Reference in New Issue
Block a user