From c600975286eeea7b4632fbddc667c44dd867491e Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:27 +0330 Subject: [PATCH] Import source: app/backend/src/routes/password-reset Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- app/backend/src/routes/password-reset.ts | 145 +++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 app/backend/src/routes/password-reset.ts diff --git a/app/backend/src/routes/password-reset.ts b/app/backend/src/routes/password-reset.ts new file mode 100644 index 0000000..01239c7 --- /dev/null +++ b/app/backend/src/routes/password-reset.ts @@ -0,0 +1,145 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { randomBytes } from 'node:crypto'; +import * as argon2 from '@node-rs/argon2'; +import { prisma } from '../db.js'; +import { redis } from '../redis.js'; +import { sendEmail } from '../lib/email.js'; +import { issueOtp, verifyOtp } from '../lib/otp.js'; +import { requireAuth, invalidateAllUserSessions } from '../plugins/session.js'; +import { checkRateLimit, rateLimitResponse } from '../lib/rate-limit.js'; +import { isPasswordAcceptable } from '../lib/password-strength.js'; +import { logSecurityEvent } from '../lib/security-audit.js'; + +const RESET_TTL_SECONDS = 60 * 60; +const resetKey = (token: string) => `pwreset:${token}`; +const emailVerifyKey = (token: string) => `emailverify:${token}`; + +async function hash(password: string) { + return argon2.hash(password, { + algorithm: argon2.Algorithm.Argon2id, + memoryCost: 19 * 1024, + timeCost: 2, + parallelism: 1, + }); +} + +export default async function passwordResetRoutes(app: FastifyInstance) { + app.post('/api/v1/auth/forgot-password', async (req, reply) => { + const rl = await checkRateLimit(req.ip, 'forgot-pw', 5, 3600); + if (rl.limited) return rateLimitResponse(reply, rl.retryAfter); + + const parsed = z.object({ email: z.string().email().max(254).toLowerCase() }).safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const user = await prisma.user.findUnique({ where: { email: parsed.data.email } }); + if (user && user.status === 'active') { + const token = randomBytes(32).toString('hex'); + await redis.set(resetKey(token), user.id, 'EX', RESET_TTL_SECONDS); + const resetUrl = `${process.env.OAUTH_CALLBACK_BASE || 'https://teknav.ir'}/#/reset-password?token=${token}`; + await sendEmail({ + to: user.email, + subject: 'بازیابی رمز عبور تکناو', + html: `
+

بازیابی رمز عبور

+

سلام ${user.name}،

+

برای تنظیم رمز عبور جدید روی دکمه زیر کلیک کنید. این لینک یک ساعت معتبر است.

+ تنظیم رمز عبور جدید +

اگر این درخواست از طرف شما نیست، این ایمیل را نادیده بگیرید.

+
`, + }); + } + + return reply.send({ ok: true }); + }); + + app.post('/api/v1/auth/reset-password', async (req, reply) => { + const rl = await checkRateLimit(req.ip, 'reset-pw', 10, 3600); + if (rl.limited) return rateLimitResponse(reply, rl.retryAfter); + + const parsed = z.object({ + token: z.string().min(1), + password: z.string().min(8).max(256), + }).safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const pwCheck = isPasswordAcceptable(parsed.data.password); + if (!pwCheck.ok) return reply.code(400).send({ error: 'weak_password', issues: pwCheck.issues }); + + const userId = await redis.get(resetKey(parsed.data.token)); + if (!userId) return reply.code(400).send({ error: 'token_invalid_or_expired' }); + + const passwordHash = await hash(parsed.data.password); + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash, passwordChangedAt: new Date() }, + }); + await redis.del(resetKey(parsed.data.token)); + await invalidateAllUserSessions(userId); + await logSecurityEvent({ type: 'password_reset', userId, ip: req.ip, userAgent: req.headers['user-agent'] }); + + return reply.send({ ok: true }); + }); + + app.post('/api/v1/auth/email/verify/request', { preHandler: requireAuth() }, async (req, reply) => { + const user = await prisma.user.findUnique({ where: { id: req.session!.userId } }); + if (!user?.email) return reply.code(400).send({ error: 'email_required' }); + if (user.emailVerifiedAt) return reply.send({ ok: true, alreadyVerified: true }); + + const token = randomBytes(32).toString('hex'); + await redis.set(emailVerifyKey(token), user.id, 'EX', RESET_TTL_SECONDS); + const verifyUrl = `${process.env.OAUTH_CALLBACK_BASE || 'https://www.teknav.ir'}/login?verifyEmail=${token}`; + await sendEmail({ + to: user.email, + subject: 'تایید ایمیل تکناو', + html: `
+

تایید ایمیل

+

سلام ${user.name}،

+

برای تایید ایمیل حساب تکناو روی دکمه زیر کلیک کنید. این لینک یک ساعت معتبر است.

+ تایید ایمیل +
`, + }); + return reply.send({ ok: true }); + }); + + app.post('/api/v1/auth/email/verify/confirm', { preHandler: requireAuth() }, async (req, reply) => { + const parsed = z.object({ token: z.string().min(1) }).safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + const userId = await redis.get(emailVerifyKey(parsed.data.token)); + if (!userId || userId !== req.session!.userId) return reply.code(400).send({ error: 'token_invalid_or_expired' }); + await prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } }); + await redis.del(emailVerifyKey(parsed.data.token)); + return reply.send({ ok: true }); + }); + + app.post('/api/v1/auth/otp/send', async (req, reply) => { + const parsed = z.object({ + phone: z.string().trim().regex(/^\+?[0-9]{8,15}$/), + }).safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_phone' }); + + const rl = `otp:rl:${parsed.data.phone}`; + const hits = await redis.incr(rl); + if (hits === 1) await redis.expire(rl, 600); + if (hits > 5) return reply.code(429).send({ error: 'too_many_otp_requests' }); + + const result = await issueOtp(parsed.data.phone); + if (!result.sent) return reply.code(503).send({ error: result.reason, provider: result.provider }); + return reply.send({ ok: true, provider: result.provider }); + }); + + app.post('/api/v1/auth/otp/verify', async (req, reply) => { + if (!req.session) return reply.code(401).send({ error: 'unauthenticated' }); + const parsed = z.object({ + phone: z.string().trim().regex(/^\+?[0-9]{8,15}$/), + code: z.string().length(6).regex(/^[0-9]{6}$/), + }).safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const valid = await verifyOtp(parsed.data.phone, parsed.data.code); + if (!valid) return reply.code(400).send({ error: 'invalid_otp' }); + + await prisma.user.update({ where: { id: req.session.userId }, data: { phone: parsed.data.phone, phoneVerifiedAt: new Date() } }); + return reply.send({ ok: true }); + }); +}