Kodax

حماية الحسابات: تحديد المعدل و MFA — Authentication & Security

حماية الحسابات: تحديد المعدل والمصادقة الثنائية

كلمة المرور المخزنة بأمان (درس التجزئة) لا تحمي من مهاجم يجربها عبر نموذج الدخول نفسه. هذا الدرس يغطي طبقتين: إبطاء المحاولات، وعامل ثانٍ لا تكفيه كلمة المرور.

الهجمات

| الهجوم | الوصف | ما يوقفه |

|---|---|---|

| التخمين (Brute force) | آلاف كلمات المرور لحساب واحد | تحديد المعدل لكل حساب، القفل التدريجي |

| رش كلمات المرور (Password spraying) | كلمة شائعة واحدة ("123456") على آلاف الحسابات | تحديد المعدل لكل IP، رفض كلمات المرور الشائعة/المسرّبة |

| حشو بيانات الاعتماد (Credential stuffing) | بريد وكلمة مرور مسرّبان من موقع آخر | MFA، فحص كلمات المرور المسرّبة، كشف الأنماط الآلية |

خوارزميات تحديد المعدل

| الخوارزمية | الفكرة | ملاحظات |

|---|---|---|

| نافذة ثابتة | 5 محاولات لكل دقيقة تبدأ عند :00 | بسيطة؛ تسمح بـ 10 محاولات حول حد النافذة |

| نافذة منزلقة | آخر 60 ثانية دائماً | أدق |

| دلو الرموز (Token bucket) | الدلو يمتلئ بمعدل ثابت وكل طلب يأخذ رمزاً | يسمح بدفعات قصيرة مع معدل متوسط محدد |

المثال الكامل ينفذ دلو الرموز.

ماذا نحدّ؟

- لكل حساب (بريد): يحمي من التخمين على حساب واحد مهما تغيرت عناوين IP.

- لكل IP: يحمي من الرش — لكن انتبه: مستخدمو شبكة جامعة أو مزود جوال يتشاركون IP واحداً.

- عام على المسارات الحساسة: التسجيل، استعادة كلمة المرور، إرسال رموز SMS (مكلفة!).

- الاستجابة: 429 Too Many Requests مع ترويسة Retry-After.

أين يُخزَّن العداد؟

في الذاكرة يعمل لخادم واحد فقط؛ مع عدة نسخ خلف موزع حمل، يحتاج كل منها رؤية نفس العداد ← Redis (أو قاعدة البيانات). مكتبة express-rate-limit تدعم مخازن خارجية.

القفل: بحذر

قفل الحساب نهائياً بعد 5 محاولات يعطي المهاجم سلاحاً: يقفل حسابات الآخرين عمداً (حرمان من الخدمة). الأفضل:

- تأخير متزايد (1ث، 2ث، 4ث…) أو قفل مؤقت قصير.

- أو طلب CAPTCHA بعد عدة محاولات فاشلة.

- إشعار المستخدم بالبريد عند محاولات مشبوهة، وإتاحة فك القفل عبر رابط.

- رسالة موحدة دائماً ("بيانات غير صحيحة") حتى لا يُكشف وجود الحساب.

المصادقة الثنائية بـ TOTP

تطبيقات مثل Google Authenticator تولّد رمزاً من 6 أرقام يتغير كل 30 ثانية. الخادم والهاتف يتشاركان سراً عند التفعيل (رمز QR)، وكلاهما يحسب:

الخطوة الزمنية T = floor(الوقت بالثواني / 30)
الرمز = آخر 6 أرقام من HMAC-SHA1(السر، T) بعد "الاقتطاع الديناميكي"   (RFC 6238 فوق RFC 4226)

- اقبل نافذة ±1 خطوة لتعويض فروق الساعة.

- امنع إعادة استخدام نفس الرمز خلال صلاحيته (سجّل آخر خطوة استُخدمت).

- طبّق تحديد المعدل على إدخال رمز MFA أيضاً (مليون احتمال فقط).

- خزّن السر مشفّراً في قاعدة البيانات (لا تجزئة: الخادم يحتاجه لحساب الرموز).

المثال الكامل ينفذ TOTP بـ node:crypto ويتحقق منه مقابل متجه الاختبار الرسمي في RFC 6238.

رموز الاسترداد

عند تفعيل MFA أعطِ المستخدم 8–10 رموز لمرة واحدة لحالة فقدان الهاتف. خزّن تجزئتها (مثل كلمات المرور)، واحذف كل رمز عند استخدامه.

أقوى من TOTP

- Passkeys / WebAuthn: مفتاح خاص على الجهاز، مقاوم للتصيد تماماً (الموقع المزيف لا يحصل على توقيع صالح). الاتجاه الحالي للصناعة.

- SMS: أفضل من لا شيء، لكنه عرضة لهجمات نقل الشريحة (SIM swap).

تمرين

أضف للمثال الكامل "تأخيراً متزايداً" لكل بريد بعد كل محاولة فاشلة، ورموز استرداد: ولّد 8 رموز، خزّن تجزئتها، واجعل استخدام الرمز مرة واحدة فقط.

const crypto = require("node:crypto");
const assert = require("node:assert/strict");

// ===== 1) TOTP (RFC 6238) بـ node:crypto =====
function hotp(secret, counter, digits = 6, algorithm = "sha1") {
  const buf = Buffer.alloc(8);
  buf.writeBigUInt64BE(BigInt(counter));
  const hmac = crypto.createHmac(algorithm, secret).update(buf).digest();
  const offset = hmac[hmac.length - 1] & 0x0f;                      // الاقتطاع الديناميكي
  const code = (hmac.readUInt32BE(offset) & 0x7fffffff) % 10 ** digits;
  return String(code).padStart(digits, "0");
}

function totp(secret, timeSeconds = Date.now() / 1000, { step = 30, digits = 6, algorithm = "sha1" } = {}) {
  return hotp(secret, Math.floor(timeSeconds / step), digits, algorithm);
}

// تحقق بنافذة ±1 خطوة، ومنع إعادة استخدام نفس الخطوة
function verifyTotp(secret, code, lastUsedStep, now = Date.now() / 1000) {
  const current = Math.floor(now / 30);
  for (const step of [current - 1, current, current + 1]) {
    if (step <= lastUsedStep) continue;                               // رمز استُخدم من قبل
    const expected = hotp(secret, step);
    if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(String(code).padStart(6, "0")))) return { ok: true, step };
  }
  return { ok: false };
}

// متجه الاختبار الرسمي من RFC 6238 (السر "12345678901234567890"، 8 أرقام، SHA1)
const rfcSecret = Buffer.from("12345678901234567890");
assert.equal(totp(rfcSecret, 59, { digits: 8 }), "94287082");
assert.equal(totp(rfcSecret, 1111111109, { digits: 8 }), "07081804");
assert.equal(totp(rfcSecret, 20000000000, { digits: 8 }), "65353130");
console.log("✅ TOTP يطابق متجهات RFC 6238");

const userSecret = crypto.randomBytes(20);                             // يُخزَّن مشفّراً لا مجزّأً
const now = 1_790_000_000;
const code = totp(userSecret, now);
const first = verifyTotp(userSecret, code, -1, now);
const replay = verifyTotp(userSecret, code, first.step, now);
console.log("الرمز:", code, "| أول استخدام:", first.ok, "| إعادة الاستخدام:", replay.ok);

// ===== 2) تحديد المعدل بدلو الرموز (لكل مفتاح: بريد أو IP) =====
function createLimiter({ capacity, refillPerSecond }) {
  const buckets = new Map();                                          // في الإنتاج: Redis
  return function take(key, nowMs = Date.now()) {
    const b = buckets.get(key) ?? { tokens: capacity, updated: nowMs };
    b.tokens = Math.min(capacity, b.tokens + ((nowMs - b.updated) / 1000) * refillPerSecond);
    b.updated = nowMs;
    if (b.tokens < 1) {
      buckets.set(key, b);
      return { allowed: false, retryAfterSeconds: Math.ceil((1 - b.tokens) / refillPerSecond) };
    }
    b.tokens -= 1;
    buckets.set(key, b);
    return { allowed: true };
  };
}

// 5 محاولات دفعة واحدة، ثم محاولة كل 12 ثانية (5 في الدقيقة)
const loginLimiter = createLimiter({ capacity: 5, refillPerSecond: 5 / 60 });
const t0 = 1_000_000;
const results = Array.from({ length: 7 }, (_, i) => loginLimiter("sara@example.com", t0 + i * 100));
console.log("المحاولات السبع الأولى:", results.map((r) => (r.allowed ? "✓" : `429 بعد ${r.retryAfterSeconds}ث`)).join(" "));
console.log("بعد 12 ثانية:", loginLimiter("sara@example.com", t0 + 12_700).allowed ? "✓" : "429");
اختبر فهمك