Rate limit account authentication attempts
This commit is contained in:
@@ -165,7 +165,7 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
|
||||
- يوفّر API الآن `POST /v1/auth/register` و`POST /v1/auth/login` و`GET /v1/auth/me` و`POST /v1/auth/logout`. كلمات المرور تُخزن بتجزئة PBKDF2 مع salt؛ رمز الجلسة العشوائي يُخزن كـSHA-256 وينتهي بعد 7 أيام ويمكن إلغاؤه. أضاف Flutter واجهة تسجيل ودخول وخروج، ويحفظ رمز الحساب عبر `flutter_secure_storage` حيث تتوفر آلية النظام. على الويب تعتمد الحزمة على تخزين المتصفح؛ دعمها تجريبي ويحتاج HTTPS أو localhost، لذا لا نعدّه حماية مماثلة لمخزن مفاتيح نظام التشغيل.
|
||||
- بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ إذا ظهر خطأ `atlstr.h` ثبّت المكوّن ثم أعد بناء التطبيق. على Android الحد الأدنى صار API 23.
|
||||
- كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب.
|
||||
- أساس API متعدد الحسابات لا يكفي لفتح الخدمة للشبكة: لا توجد حدود لمحاولات الدخول أو استعادة كلمة المرور، ويجب حصر مساحات العمل التي يستطيع كل حساب قراءتها ضمن مجلدات مصرح بها؛ عملية FastAPI ترى صلاحيات ملفات نظام التشغيل الذي يشغلها. أبقِ الخدمة على `127.0.0.1` إلى أن تكتمل هذه الضوابط ويُختبر النشر عبر TLS.
|
||||
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy؛ ولا توجد استعادة لكلمة المرور. كذلك يجب حصر مساحات العمل التي يستطيع كل حساب قراءتها ضمن مجلدات مصرح بها؛ عملية FastAPI ترى صلاحيات ملفات نظام التشغيل الذي يشغلها. أبقِ الخدمة على `127.0.0.1` إلى أن تكتمل هذه الضوابط ويُختبر النشر عبر TLS.
|
||||
- رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل.
|
||||
- مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع.
|
||||
- على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
- التدريب والضبط الدقيق وتوزيع Windows مراحل لاحقة، وليست مما يفعّله التطبيق حاليًا.
|
||||
- 2026-10-03: اكتمل فرض Bearer على كل عمليات `/v1` الخاصة (عدا الصحة وقائمة النماذج ومسارات بدء المصادقة العامة)، وربط Flutter بالجلسة لكل طلب محادثة/وكيل/ملف/معرفة/ويب/صوت. أصبحت ملكية فهرس المعرفة وسجل التدقيق حسب الحساب، ومعاينة تعديل الملف لا تُطبق إلا بجلسة صاحبها. تحقق OpenAPI وواجهات رفض الرمز وعزل حسابين وترحيل SQLite: 61 اختبار Python ناجح، و11 اختبار Flutter وتحليل Flutter بلا ملاحظات. يبقى قصر الوصول إلى مسارات نظام الملفات لكل مستخدم، وواجهة الدخول وتخزين الرمز الآمن وحدود محاولات الدخول؛ لذلك يظل التشغيل loopback فقط.
|
||||
- 2026-10-03: أضيفت شاشة Flutter للحساب (إنشاء/دخول/خروج)، وعند تبديل الهوية تمسح المحادثات المحملة ثم تعيد قراءتها تحت الجلسة الجديدة. رمز الحساب يمر عبر `flutter_secure_storage` ويُستعاد بالتحقق من `/v1/auth/me`؛ اختبار Flutter 11/11 و`flutter analyze` بلا ملاحظات. Android مضبوط على API 23. **لم يكتمل بناء Windows:** Visual Studio Build Tools يفتقد ATL (`atlstr.h`) المطلوب من الإضافة، ومحاولة إضافة المكوّن لم تُثبته؛ يجب إصلاح مكوّن البناء ثم إعادة `flutter build windows --debug` قبل اعتبار خطوة Windows مجربة. بناء الويب بدأ لكن لم يُحسم بعد.
|
||||
- 2026-10-03: أضيف تحديد دائم لمحاولات الدخول عبر SQLite: خمس كلمات مرور خاطئة لكل حساب أو عنوان اتصال خلال 15 دقيقة، وحدّ إنشاء 30 حسابًا لكل اتصال في الساعة. تُخزن بصمات SHA-256 للمفاتيح بدل البريد أو IP الخام، ويرجع الخادم 429 و`Retry-After`. تحقق اختبار انقضاء النافذة ومجموعة Python الكاملة: 62 اختبارًا ناجحًا، و`compileall` و`git diff --check` ناجحان. لا يزال هذا حدًا أوليًا خلف خدمة loopback، وليس سياسة كافية وحده لخدمة عامة.
|
||||
|
||||
## المرحلة 1 — تجربة المحادثة
|
||||
|
||||
@@ -68,6 +69,7 @@
|
||||
- [x] بناء واجهة إنشاء الحساب/الدخول والخروج وربطها بجلسات API؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة.
|
||||
- [x] حفظ رمز الحساب عبر `flutter_secure_storage` في مخزن النظام على المنصات المدعومة، واستعادة `/v1/auth/me` عند بدء التطبيق؛ Android مضبوط على API 23 كحد أدنى. تخزين الويب تجريبي ويعتمد على المتصفح، لذلك لا يُعامل كحماية نظام تشغيل.
|
||||
- [ ] إضافة حدود لمحاولات الدخول/التسجيل وتدفق استعادة كلمة المرور واختبار نشر محلي عبر TLS قبل الوصول الشبكي.
|
||||
- [x] حد أولي دائم لتخمين كلمات المرور وإنشاء الحسابات في SQLite مع نافذة انتهاء و`Retry-After`؛ يلزم اختبار API كامل ومراجعة الحدود تحت reverse proxy موثوق قبل أي نشر خارجي.
|
||||
- [ ] تهيئة تخزين آمن للحساب على الويب أو تعطيل حفظ الرمز هناك؛ التحقق من متطلبات Linux keyring وWindows/macOS على منصات بناء فعلية.
|
||||
- [ ] تثبيت/تفعيل مكوّن C++ ATL في Visual Studio Build Tools وإعادة بناء Windows؛ المحاولة بتاريخ 2026-10-03 ما زالت تُظهر الخطأ `atlstr.h` المفقود.
|
||||
- تصميم بيانات المستخدمين والمحادثات والمرفقات ونسخ الإجابات مع ملكية واضحة وفهارس وترحيلات قاعدة بيانات.
|
||||
|
||||
@@ -16,6 +16,10 @@ from app import database
|
||||
|
||||
PASSWORD_ITERATIONS = 310_000
|
||||
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
|
||||
LOGIN_FAILURE_LIMIT = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
REGISTRATION_LIMIT = 30
|
||||
REGISTRATION_WINDOW_SECONDS = 60 * 60
|
||||
_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
_DUMMY_SALT = bytes.fromhex("4f2bc994cb56a7a98b253a615fb57d2c")
|
||||
_DUMMY_DIGEST = hashlib.pbkdf2_hmac(
|
||||
@@ -30,6 +34,116 @@ def normalize_email(email: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _fingerprint(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
def _rate_limit_keys(email: str, client_host: str) -> tuple[tuple[str, str], ...]:
|
||||
normalized = normalize_email(email)
|
||||
return (
|
||||
("login-email", _fingerprint(normalized)),
|
||||
("login-client", _fingerprint(client_host or "unknown")),
|
||||
)
|
||||
|
||||
|
||||
def login_retry_after(email: str, client_host: str, *, now: int | None = None) -> int:
|
||||
"""Return seconds until a login attempt is allowed, or zero when clear."""
|
||||
current = int(time.time()) if now is None else now
|
||||
retry = 0
|
||||
with database._connect() as connection:
|
||||
for scope, fingerprint in _rate_limit_keys(email, client_host):
|
||||
row = connection.execute(
|
||||
"SELECT window_started_at, attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
||||
(scope, fingerprint),
|
||||
).fetchone()
|
||||
if row is None or row["attempts"] < LOGIN_FAILURE_LIMIT:
|
||||
continue
|
||||
remaining = LOGIN_WINDOW_SECONDS - (current - row["window_started_at"])
|
||||
retry = max(retry, remaining)
|
||||
connection.execute(
|
||||
"DELETE FROM auth_rate_limits WHERE window_started_at + ? <= ?",
|
||||
(LOGIN_WINDOW_SECONDS, current),
|
||||
)
|
||||
return max(0, retry)
|
||||
|
||||
|
||||
def record_login_failure(email: str, client_host: str, *, now: int | None = None) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
with database._connect() as connection:
|
||||
for scope, fingerprint in _rate_limit_keys(email, client_host):
|
||||
connection.execute(
|
||||
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
|
||||
VALUES(?,?,?,1)
|
||||
ON CONFLICT(scope,fingerprint) DO UPDATE SET
|
||||
window_started_at=CASE
|
||||
WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
|
||||
attempts=CASE
|
||||
WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
|
||||
(
|
||||
scope, fingerprint, current,
|
||||
LOGIN_WINDOW_SECONDS, current, current,
|
||||
LOGIN_WINDOW_SECONDS, current,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def clear_login_failures(email: str, client_host: str) -> None:
|
||||
with database._connect() as connection:
|
||||
connection.executemany(
|
||||
"DELETE FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
||||
_rate_limit_keys(email, client_host),
|
||||
)
|
||||
|
||||
|
||||
def registration_retry_after(client_host: str, *, now: int | None = None) -> int:
|
||||
return _retry_after(
|
||||
"register-client", client_host or "unknown", REGISTRATION_LIMIT,
|
||||
REGISTRATION_WINDOW_SECONDS, now=now,
|
||||
)
|
||||
|
||||
|
||||
def record_registration_attempt(client_host: str, *, now: int | None = None) -> None:
|
||||
_record_attempt(
|
||||
"register-client", client_host or "unknown", REGISTRATION_WINDOW_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def _retry_after(
|
||||
scope: str, value: str, limit: int, duration: int, *, now: int | None = None
|
||||
) -> int:
|
||||
current = int(time.time()) if now is None else now
|
||||
fingerprint = _fingerprint(value)
|
||||
with database._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT window_started_at,attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
||||
(scope, fingerprint),
|
||||
).fetchone()
|
||||
if row is None or row["attempts"] < limit:
|
||||
return 0
|
||||
return max(0, duration - (current - row["window_started_at"]))
|
||||
|
||||
|
||||
def _record_attempt(
|
||||
scope: str, value: str, duration: int, *, now: int | None = None
|
||||
) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
fingerprint = _fingerprint(value)
|
||||
with database._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
|
||||
VALUES(?,?,?,1)
|
||||
ON CONFLICT(scope,fingerprint) DO UPDATE SET
|
||||
window_started_at=CASE WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
|
||||
attempts=CASE WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
|
||||
(
|
||||
scope, fingerprint, current,
|
||||
duration, current, current,
|
||||
duration, current,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
|
||||
@@ -71,6 +71,14 @@ def initialize_database() -> None:
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
|
||||
ON auth_sessions(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
scope TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
window_started_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL CHECK(attempts >= 0),
|
||||
PRIMARY KEY(scope, fingerprint)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1179,7 +1179,16 @@ def create_local_session(request: Request) -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/v1/auth/register", status_code=201)
|
||||
def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
|
||||
def register_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
|
||||
client_host = request.client.host if request.client is not None else "unknown"
|
||||
retry_after = auth.registration_retry_after(client_host)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="تم بلوغ حد إنشاء الحسابات من هذا الاتصال؛ حاول لاحقًا.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
auth.record_registration_attempt(client_host)
|
||||
try:
|
||||
user_id = auth.create_account(credentials.email, credentials.password)
|
||||
email = auth.normalize_email(credentials.email)
|
||||
@@ -1190,13 +1199,27 @@ def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/v1/auth/login")
|
||||
def login_account(credentials: PasswordCredentials) -> dict[str, Any]:
|
||||
def login_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
|
||||
try:
|
||||
account = auth.authenticate(credentials.email, credentials.password)
|
||||
email = auth.normalize_email(credentials.email)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
client_host = request.client.host if request.client is not None else "unknown"
|
||||
retry_after = auth.login_retry_after(email, client_host)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="محاولات الدخول كثيرة؛ انتظر انتهاء المهلة ثم أعد المحاولة.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
account = auth.authenticate(email, credentials.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if account is None:
|
||||
auth.record_login_failure(email, client_host)
|
||||
raise HTTPException(status_code=401, detail="البريد الإلكتروني أو كلمة المرور غير صحيحة.")
|
||||
auth.clear_login_failures(email, client_host)
|
||||
user_id, email = account
|
||||
return _auth_response(user_id, email, "account")
|
||||
|
||||
|
||||
@@ -185,6 +185,20 @@ class AuthenticationTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(duplicate.status_code, 409, duplicate.text)
|
||||
|
||||
def test_login_failures_are_rate_limited_and_expire(self) -> None:
|
||||
email = f"{uuid4().hex}@example.test"
|
||||
client_host = f"rate-limit-test-{uuid4().hex}"
|
||||
now = 1_800_000_000
|
||||
for _ in range(auth.LOGIN_FAILURE_LIMIT):
|
||||
auth.record_login_failure(email, client_host, now=now)
|
||||
|
||||
self.assertEqual(auth.login_retry_after(email, client_host, now=now), 900)
|
||||
self.assertEqual(
|
||||
auth.login_retry_after(email, client_host, now=now + auth.LOGIN_WINDOW_SECONDS),
|
||||
0,
|
||||
)
|
||||
auth.clear_login_failures(email, client_host)
|
||||
|
||||
def test_local_bootstrap_is_restricted_to_loopback_clients(self) -> None:
|
||||
remote = self.client.post("/v1/auth/local-session", json={})
|
||||
self.assertEqual(remote.status_code, 403, remote.text)
|
||||
|
||||
Reference in New Issue
Block a user