مشروع: نظام مصادقة كامل — 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}`);
}اختبر فهمك