Kodax

مشروع: نظام مصادقة كامل — Authentication & Security

مشروع: نظام مصادقة كامل

ستبني وحدة مصادقة قابلة لإعادة الاستخدام في أي API. معيار النجاح ليس "يعمل" بل "يصمد أمام قائمة الهجمات" في آخر الدرس.

التدفقات

التسجيل:    بريد + كلمة مرور ← تجزئة (Argon2id/bcrypt) ← حساب غير موثّق ← رابط تحقق (رمز لمرة واحدة)
الدخول:     تحديد معدل ← تحقق ← (MFA إن مفعّل) ← رمز وصول 15د + رمز تجديد في cookie HttpOnly
التجديد:    تدوير الرمز + كشف إعادة الاستخدام (درس Refresh Tokens)
الاستعادة:  رسالة موحدة ← رمز عشوائي (تجزئته في القاعدة، صلاحية 30د) ← كلمة جديدة ← إلغاء كل الجلسات
الخروج:     حذف عائلة رمز التجديد + مسح الـ cookie

الجداول

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  email_verified_at TIMESTAMPTZ,
  password_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  mfa_secret_encrypted TEXT,
  mfa_last_step BIGINT NOT NULL DEFAULT -1,
  role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))
);
-- رموز لمرة واحدة: التحقق من البريد واستعادة كلمة المرور (التجزئة فقط)
CREATE TABLE one_time_tokens (
  token_hash TEXT PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  purpose TEXT NOT NULL CHECK (purpose IN ('verify_email', 'reset_password')),
  expires_at TIMESTAMPTZ NOT NULL,
  used_at TIMESTAMPTZ
);
CREATE TABLE refresh_tokens (
  token_hash TEXT PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  family_id UUID NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  used_at TIMESTAMPTZ
);
CREATE INDEX refresh_tokens_family_idx ON refresh_tokens(family_id);

قرارات تستحق الانتباه

| القرار | السبب |

|---|---|

| رسائل موحدة في الدخول والاستعادة | لا تكشف وجود الحساب (User Enumeration) |

| تجزئة الرموز لمرة واحدة ورموز التجديد | تسريب القاعدة لا يعطي رموزاً صالحة |

| password_changed_at في فحص الرموز | تغيير كلمة المرور يُبطل رموز الوصول القديمة |

| mfa_last_step | منع إعادة استخدام رمز TOTP |

| الدور من القاعدة لا من الطلب | منع رفع الصلاحيات |

| حد للمعدل على الدخول والاستعادة والتسجيل وإعادة إرسال البريد | منع التخمين وإساءة إرسال الرسائل |

قائمة الهجمات (اختبارات يجب أن تنجح)

- [ ] 20 محاولة دخول خاطئة متتالية ← 429 مع Retry-After.

- [ ] "بريد غير موجود" و"كلمة مرور خاطئة" ← نفس الرسالة ونفس الزمن تقريباً (جزّئ كلمة مرور وهمية عند عدم وجود البريد).

- [ ] استخدام رابط الاستعادة مرتين ← المرة الثانية تفشل؛ رابط منتهٍ ← يفشل.

- [ ] بعد تغيير كلمة المرور ← رمز الوصول القديم وكل رموز التجديد مرفوضة.

- [ ] إعادة استخدام رمز تجديد قديم ← إلغاء العائلة كلها.

- [ ] رمز TOTP مستخدم ← مرفوض حتى داخل نافذته.

- [ ] POST /register بحقل role: "admin" ← يُتجاهل.

- [ ] لا يظهر password_hash أو الأسرار في أي استجابة أو سجل.

- [ ] ملفات تعريف الارتباط: HttpOnly و Secure و SameSite.

بعد المشروع

قارن ما بنيته بخدمة جاهزة (Supabase Auth، Auth.js، Clerk): كل بند في القائمة يجب أن تجده هناك. القرار الصحيح لكثير من المشاريع هو استخدام خدمة مُختبرة — وأنت الآن تعرف كيف تقيّمها وتستخدمها بشكل صحيح.

// auth.routes.js — مسارات الدخول والاستعادة بمبادئ المشروع (npm install express argon2 zod)
import crypto from "node:crypto";
import express from "express";
import argon2 from "argon2";
import { z } from "zod";
import { pool } from "../db/pool.js";
import { rateLimit } from "../lib/rateLimit.js";      // دلو رموز على Redis (درس حماية الحسابات)
import { issueSession } from "./sessions.js";          // رمز وصول + تجديد بالتدوير (درس Refresh Tokens)
import { sendEmail } from "../lib/mailer.js";

export const authRouter = express.Router();
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
const credentials = z.object({ email: z.string().email().toLowerCase(), password: z.string().min(8).max(200) });

// تجزئة وهمية: يستغرق الرفض نفس الزمن سواء وُجد البريد أم لا
const DUMMY_HASH = await argon2.hash("dummy-password-for-timing");

authRouter.post("/register", rateLimit({ key: (req) => req.ip, capacity: 5, perMinute: 5 }), async (req, res) => {
  const { email, password } = credentials.parse(req.body);   // role وغيره يُتجاهل: لا يدخل المخطط
  const hash = await argon2.hash(password, { type: argon2.argon2id });
  const { rows } = await pool.query(
    "INSERT INTO users (email, password_hash) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING RETURNING id",
    [email, hash]
  );
  if (rows[0]) await sendVerification(rows[0].id, email);
  // نفس الاستجابة في الحالتين: لا نكشف أن البريد مسجل مسبقاً
  res.status(202).json({ message: "إن كان البريد صالحاً ستصلك رسالة تأكيد" });
});

authRouter.post("/login", rateLimit({ key: (req) => `login:${String(req.body?.email).toLowerCase()}`, capacity: 5, perMinute: 5 }), async (req, res) => {
  const { email, password } = credentials.parse(req.body);
  const { rows: [user] } = await pool.query("SELECT id, password_hash, email_verified_at, role FROM users WHERE email = $1", [email]);
  const valid = await argon2.verify(user?.password_hash ?? DUMMY_HASH, password);
  if (!user || !valid) return res.status(401).json({ error: "بيانات الدخول غير صحيحة" });
  if (!user.email_verified_at) return res.status(403).json({ error: "أكّد بريدك أولاً" });
  await issueSession(res, user);      // يضع cookie التجديد ويعيد رمز الوصول
});

authRouter.post("/password/forgot", rateLimit({ key: (req) => req.ip, capacity: 3, perMinute: 1 }), async (req, res) => {
  const email = z.string().email().toLowerCase().parse(req.body?.email);
  const { rows: [user] } = await pool.query("SELECT id FROM users WHERE email = $1", [email]);
  if (user) {
    const token = crypto.randomBytes(32).toString("base64url");
    await pool.query(
      "INSERT INTO one_time_tokens (token_hash, user_id, purpose, expires_at) VALUES ($1, $2, 'reset_password', NOW() + interval '30 minutes')",
      [sha256(token), user.id]
    );
    await sendEmail(email, "استعادة كلمة المرور", `${process.env.APP_URL}/reset?token=${token}`);
  }
  res.status(202).json({ message: "إن كان الحساب موجوداً ستصلك رسالة" });
});

authRouter.post("/password/reset", rateLimit({ key: (req) => req.ip, capacity: 5, perMinute: 5 }), async (req, res) => {
  const { token, password } = z.object({ token: z.string().min(20), password: z.string().min(8).max(200) }).parse(req.body);
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // يُستهلك الرمز ذرياً: مرة واحدة وقبل انتهاء صلاحيته
    const { rows: [t] } = await client.query(
      `UPDATE one_time_tokens SET used_at = NOW()
       WHERE token_hash = $1 AND purpose = 'reset_password' AND used_at IS NULL AND expires_at > NOW()
       RETURNING user_id`,
      [sha256(token)]
    );
    if (!t) { await client.query("ROLLBACK"); return res.status(400).json({ error: "الرابط غير صالح أو منتهٍ" }); }
    const hash = await argon2.hash(password, { type: argon2.argon2id });
    await client.query("UPDATE users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2", [hash, t.user_id]);
    await client.query("DELETE FROM refresh_tokens WHERE user_id = $1", [t.user_id]);   // إلغاء كل الجلسات
    await client.query("COMMIT");
    res.json({ message: "تم تغيير كلمة المرور؛ سجّل الدخول من جديد" });
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
});

async function sendVerification(userId, email) {
  const token = crypto.randomBytes(32).toString("base64url");
  await pool.query(
    "INSERT INTO one_time_tokens (token_hash, user_id, purpose, expires_at) VALUES ($1, $2, 'verify_email', NOW() + interval '24 hours')",
    [sha256(token), userId]
  );
  await sendEmail(email, "تأكيد البريد", `${process.env.APP_URL}/verify?token=${token}`);
}
اختبر فهمك