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 });
+ });
+}