Add one-time password recovery flow
This commit is contained in:
@@ -163,9 +163,10 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
|
||||
- تحفظ FastAPI المحادثات والرسائل في SQLite محليًا. على Windows يوجد الملف في `%LOCALAPPDATA%\SovereignAI\data\sovereign_ai.sqlite3`، وخارج مجلد المشروع لتجنب مزامنة قاعدة البيانات مع OneDrive.
|
||||
- لكل سجل محادثة `user_id`، وتُفلتر القراءة والتعديل والحذف بهوية جلسة Bearer موثقة؛ لم يعد `X-User-ID` يمنح أي صلاحية. في الوضع المحلي تطلب الواجهة تلقائيًا جلسة للمستخدم المحلي، ولا يصدرها الخادم إلا لاتصال loopback.
|
||||
- يوفّر 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` في تطبيقات الأنظمة الأصلية. على الويب يبقى الرمز في الذاكرة فقط ولا يُحفظ بعد إعادة تحميل الصفحة أو إغلاق التبويب؛ بذلك يحتاج المستخدم لتسجيل الدخول مجددًا. هذا يتجنب التخزين الدائم في JavaScript-accessible browser storage، لكنه لا يمنع سرقة الجلسة أثناء تشغيل صفحة مخترقة؛ يحتاج النشر العام مراجعة حماية XSS وHTTPS.
|
||||
- استعادة كلمة المرور عبر `POST /v1/auth/password-reset/request` ثم `POST /v1/auth/password-reset/complete`. الإرسال معطل حتى تضبط على خادم FastAPI: `SOVEREIGNAI_SMTP_HOST`, `SOVEREIGNAI_SMTP_PORT` (587 افتراضيًا أو 465 مع SSL), `SOVEREIGNAI_SMTP_SECURITY` (`starttls` افتراضيًا أو `ssl`), `SOVEREIGNAI_SMTP_FROM`, وبيانات الدخول الاختيارية `SOVEREIGNAI_SMTP_USERNAME`/`SOVEREIGNAI_SMTP_PASSWORD`. لا تحفظ كلمة مرور SMTP في Flutter أو Git. رمز الاستعادة عمره 30 دقيقة، بصمته فقط محفوظة، ويُستخدم مرة واحدة؛ تغيير كلمة المرور يلغي جلسات الحساب السابقة. رد طلب الاستعادة عام، والميزة تعيد 503 إن لم تُهيأ خدمة SMTP.
|
||||
- بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ ATL مثبت الآن ونجح بناء Debug على هذا الجهاز. على Android الحد الأدنى صار API 23.
|
||||
- كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب.
|
||||
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy؛ ولا توجد استعادة لكلمة المرور. يخصص `SOVEREIGNAI_USER_WORKSPACES` مجلدات منفصلة للحسابات ضمن جذور `SOVEREIGNAI_ALLOWED_WORKSPACES`، لكن عملية FastAPI ما زالت ترث صلاحيات نظام التشغيل. أبقِ الخدمة على `127.0.0.1` إلى أن يكتمل العزل على مستوى العملية ويُختبر TLS.
|
||||
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات وطلبات استعادة كلمة المرور في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy. يخصص `SOVEREIGNAI_USER_WORKSPACES` مجلدات منفصلة للحسابات ضمن جذور `SOVEREIGNAI_ALLOWED_WORKSPACES`، لكن عملية FastAPI ما زالت ترث صلاحيات نظام التشغيل. أبقِ الخدمة على `127.0.0.1` إلى أن يكتمل العزل على مستوى العملية ويُختبر TLS.
|
||||
- رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل.
|
||||
- مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع.
|
||||
- على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
- [x] بناء واجهة إنشاء الحساب/الدخول والخروج وربطها بجلسات API؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة.
|
||||
- [x] حفظ رمز الحساب عبر `flutter_secure_storage` في مخزن النظام على المنصات الأصلية المدعومة، واستعادة `/v1/auth/me` عند بدء التطبيق؛ Android مضبوط على API 23 كحد أدنى. الويب لا يحفظ رمز الجلسة دائمًا، ويحتفظ به في الذاكرة فقط حتى إعادة التحميل أو إغلاق التبويب.
|
||||
- [x] اختبار مخزن جلسة الويب المؤقت (2/2) وبناء Flutter Web ناجح (2026-10-03). رسالة الدخول توضح انتهاء الجلسة عند إعادة تحميل الصفحة.
|
||||
- [ ] إضافة تدفق استعادة كلمة المرور واختبار نشر محلي عبر TLS قبل الوصول الشبكي.
|
||||
- [x] إضافة استعادة كلمة المرور عبر SMTP: رمز عشوائي 30 دقيقة بصمة SHA-256، لمرة واحدة، تحديد طلبات حسب البريد والعميل، رسالة API عامة، وتسجيل خروج كل الجلسات القديمة بعد تغيير كلمة المرور. مسارات Flutter للطلب وإدخال الرمز وتغيير كلمة المرور مضافة؛ 12 اختبار مصادقة نجحت، و`flutter analyze` نظيف (2026-10-03). يتطلب الإرسال إعداد متغيرات SMTP الموثقة في README.
|
||||
- [ ] تشغيل نسخة FastAPI محلية عبر TLS والتحقق من الشهادة والاتصال من Flutter قبل السماح بالوصول الشبكي.
|
||||
- [x] حد أولي دائم لتخمين كلمات المرور وإنشاء الحسابات في SQLite مع نافذة انتهاء و`Retry-After`؛ اختبرته مجموعة Python كاملة، وتبقى مراجعة الحدود تحت reverse proxy موثوق قبل أي نشر خارجي.
|
||||
- [x] تعطيل حفظ رمز الجلسة الدائم في الويب؛ استخدام مخزن ذاكرة مؤقت وإظهار ذلك للمستخدم.
|
||||
- [ ] التحقق من متطلبات Linux keyring وتشغيل تخزين الجلسة الأصلي، وبناء macOS وLinux على منصات فعلية.
|
||||
|
||||
@@ -16,6 +16,7 @@ from app import database
|
||||
|
||||
PASSWORD_ITERATIONS = 310_000
|
||||
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
|
||||
PASSWORD_RESET_LIFETIME_SECONDS = 30 * 60
|
||||
LOGIN_FAILURE_LIMIT = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
REGISTRATION_LIMIT = 30
|
||||
@@ -102,6 +103,26 @@ def registration_retry_after(client_host: str, *, now: int | None = None) -> int
|
||||
)
|
||||
|
||||
|
||||
def password_reset_retry_after(
|
||||
email: str, client_host: str, *, now: int | None = None
|
||||
) -> int:
|
||||
current = int(time.time()) if now is None else now
|
||||
normalized = normalize_email(email)
|
||||
return max(
|
||||
_retry_after("password-reset-email", normalized, 3, 60 * 60, now=current),
|
||||
_retry_after("password-reset-client", client_host or "unknown", 10, 60 * 60, now=current),
|
||||
)
|
||||
|
||||
|
||||
def record_password_reset_attempt(
|
||||
email: str, client_host: str, *, now: int | None = None
|
||||
) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
normalized = normalize_email(email)
|
||||
_record_attempt("password-reset-email", normalized, 60 * 60, now=current)
|
||||
_record_attempt("password-reset-client", client_host or "unknown", 60 * 60, now=current)
|
||||
|
||||
|
||||
def record_registration_attempt(client_host: str, *, now: int | None = None) -> None:
|
||||
_record_attempt(
|
||||
"register-client", client_host or "unknown", REGISTRATION_WINDOW_SECONDS,
|
||||
@@ -197,6 +218,54 @@ def create_account(email: str, password: str) -> str:
|
||||
return user_id
|
||||
|
||||
|
||||
def issue_password_reset(email: str, *, now: int | None = None) -> str | None:
|
||||
normalized = normalize_email(email)
|
||||
current = int(time.time()) if now is None else now
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode("ascii")).hexdigest()
|
||||
with database._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT user_id FROM user_identities WHERE provider='password' AND provider_subject=?",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE expires_at <= ?", (current,))
|
||||
if row is None:
|
||||
return None
|
||||
connection.execute(
|
||||
"DELETE FROM password_reset_tokens WHERE user_id=?", (row["user_id"],)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES(?,?,?)",
|
||||
(token_hash, row["user_id"], current + PASSWORD_RESET_LIFETIME_SECONDS),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def reset_password(token: str, password: str, *, now: int | None = None) -> bool:
|
||||
if not 12 <= len(password) <= 256 or not token or len(token) > 256:
|
||||
return False
|
||||
current = int(time.time()) if now is None else now
|
||||
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
|
||||
with database._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT user_id FROM password_reset_tokens WHERE token_hash=? AND expires_at>?",
|
||||
(token_hash, current),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE token_hash=?", (token_hash,))
|
||||
return False
|
||||
user_id = row["user_id"]
|
||||
password_hash = _hash_password(password)
|
||||
connection.execute(
|
||||
"UPDATE user_identities SET password_hash=? WHERE user_id=? AND provider='password'",
|
||||
(password_hash, user_id),
|
||||
)
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE user_id=?", (user_id,))
|
||||
connection.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,))
|
||||
return True
|
||||
|
||||
|
||||
def authenticate(email: str, password: str) -> tuple[str, str] | None:
|
||||
normalized = normalize_email(email)
|
||||
with database._connect() as connection:
|
||||
|
||||
@@ -71,6 +71,15 @@ 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 password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_user
|
||||
ON password_reset_tokens(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
scope TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
|
||||
@@ -25,6 +25,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app import database
|
||||
from app import auth
|
||||
from app import password_reset_email
|
||||
from app.model_provider import get_model_provider
|
||||
from app import workspace
|
||||
from app import skills
|
||||
@@ -295,6 +296,15 @@ class PasswordCredentials(BaseModel):
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=254)
|
||||
|
||||
|
||||
class PasswordResetCompletion(BaseModel):
|
||||
token: str = Field(min_length=32, max_length=256)
|
||||
new_password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class WebReadRequest(BaseModel):
|
||||
url: str = Field(min_length=8, max_length=2048, description="رابط صفحة ويب عامة تريد تحليلها")
|
||||
question: str = Field(default="لخّص محتوى الصفحة وأهم نقاطها.", min_length=1, max_length=2000)
|
||||
@@ -1209,6 +1219,49 @@ def register_account(credentials: PasswordCredentials, request: Request) -> dict
|
||||
return _auth_response(user_id, email, "account")
|
||||
|
||||
|
||||
@app.post("/v1/auth/password-reset/request", status_code=202)
|
||||
def request_password_reset(payload: PasswordResetRequest, request: Request) -> dict[str, str]:
|
||||
try:
|
||||
email = auth.normalize_email(payload.email)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if not password_reset_email.smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="استعادة كلمة المرور غير مهيأة؛ أعد لاحقًا بعد إعداد SMTP.",
|
||||
)
|
||||
client_host = request.client.host if request.client is not None else "unknown"
|
||||
retry_after = auth.password_reset_retry_after(email, client_host)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="طلبات الاستعادة كثيرة؛ حاول بعد انتهاء المهلة.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
auth.record_password_reset_attempt(email, client_host)
|
||||
token = auth.issue_password_reset(email)
|
||||
try:
|
||||
password_reset_email.send_password_reset_email(email, token)
|
||||
except Exception as exc:
|
||||
# Do not log the destination address, token, SMTP transcript, or credentials.
|
||||
logger.warning("Password reset email delivery failed (%s)", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="تعذر إرسال رسالة الاستعادة الآن؛ حاول لاحقًا.",
|
||||
) from None
|
||||
return {
|
||||
"status": "accepted",
|
||||
"message": "إذا كان البريد مرتبطًا بحساب، فستصلك رسالة استعادة.",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/auth/password-reset/complete")
|
||||
def complete_password_reset(payload: PasswordResetCompletion) -> dict[str, str]:
|
||||
if not auth.reset_password(payload.token, payload.new_password):
|
||||
raise HTTPException(status_code=400, detail="رمز الاستعادة غير صالح أو منتهي الصلاحية.")
|
||||
return {"status": "password_reset"}
|
||||
|
||||
|
||||
@app.post("/v1/auth/login")
|
||||
def login_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""SMTP delivery for one-time account recovery messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
|
||||
def smtp_configured() -> bool:
|
||||
return bool(
|
||||
os.getenv("SOVEREIGNAI_SMTP_HOST", "").strip()
|
||||
and os.getenv("SOVEREIGNAI_SMTP_FROM", "").strip()
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_email(address: str, token: str | None) -> None:
|
||||
host = os.getenv("SOVEREIGNAI_SMTP_HOST", "").strip()
|
||||
sender = os.getenv("SOVEREIGNAI_SMTP_FROM", "").strip()
|
||||
if not host or not sender:
|
||||
raise RuntimeError("SMTP recovery email is not configured")
|
||||
security = os.getenv("SOVEREIGNAI_SMTP_SECURITY", "starttls").strip().casefold()
|
||||
if security not in {"starttls", "ssl"}:
|
||||
raise RuntimeError("SOVEREIGNAI_SMTP_SECURITY must be starttls or ssl")
|
||||
port = int(os.getenv("SOVEREIGNAI_SMTP_PORT", "465" if security == "ssl" else "587"))
|
||||
username = os.getenv("SOVEREIGNAI_SMTP_USERNAME", "")
|
||||
password = os.getenv("SOVEREIGNAI_SMTP_PASSWORD", "")
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = "استعادة كلمة مرور SovereignAI"
|
||||
message["From"] = sender
|
||||
message["To"] = address
|
||||
message.set_content(
|
||||
"إذا كان لهذا البريد حساب في SovereignAI، استخدم رمز الاستعادة التالي خلال 30 دقيقة.\n\n"
|
||||
f"{token if token is not None else 'لا يوجد رمز صالح لهذا الطلب.'}\n\n"
|
||||
"إذا لم تطلب الاستعادة، تجاهل هذه الرسالة."
|
||||
)
|
||||
|
||||
smtp_type = smtplib.SMTP_SSL if security == "ssl" else smtplib.SMTP
|
||||
client = (
|
||||
smtp_type(host, port, timeout=10, context=ssl.create_default_context())
|
||||
if security == "ssl"
|
||||
else smtp_type(host, port, timeout=10)
|
||||
)
|
||||
with client:
|
||||
if security == "starttls":
|
||||
client.starttls(context=ssl.create_default_context())
|
||||
if username:
|
||||
client.login(username, password)
|
||||
client.send_message(message)
|
||||
@@ -229,6 +229,27 @@ class ApiRepository {
|
||||
required String password,
|
||||
}) => _authenticateAccount('login', email, password);
|
||||
|
||||
Future<void> requestPasswordReset(String email) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/v1/auth/password-reset/request'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'email': email}),
|
||||
);
|
||||
_checkStatus(response);
|
||||
}
|
||||
|
||||
Future<void> completePasswordReset({
|
||||
required String token,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/v1/auth/password-reset/complete'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'token': token, 'new_password': newPassword}),
|
||||
);
|
||||
_checkStatus(response);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _authenticateAccount(
|
||||
String action,
|
||||
String email,
|
||||
|
||||
@@ -578,6 +578,15 @@ class _ChatPageState extends State<ChatPage> {
|
||||
: 'ليس لديك حساب؟ أنشئ حسابًا',
|
||||
),
|
||||
),
|
||||
if (!register)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final email = emailController.text.trim();
|
||||
Navigator.pop(dialogContext);
|
||||
_showPasswordResetFlow(api, email);
|
||||
},
|
||||
child: const Text('نسيت كلمة المرور؟'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -654,6 +663,143 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showPasswordResetFlow(
|
||||
ApiRepository api,
|
||||
String initialEmail,
|
||||
) async {
|
||||
final emailController = TextEditingController(text: initialEmail);
|
||||
final email = await showDialog<String>(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: AlertDialog(
|
||||
title: const Text('استعادة كلمة المرور'),
|
||||
content: TextField(
|
||||
controller: emailController,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'البريد الإلكتروني',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('إلغاء'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(
|
||||
dialogContext,
|
||||
emailController.text.trim(),
|
||||
),
|
||||
child: const Text('إرسال الرمز'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
emailController.dispose();
|
||||
if (email == null || email.isEmpty || !mounted) return;
|
||||
|
||||
try {
|
||||
await api.requestPasswordReset(email);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('إذا كان البريد مرتبطًا بحساب، فستصلك رسالة استعادة.'),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error.toString().replaceFirst('Exception: ', '')),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final tokenController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final reset = await showDialog<(String, String)?>(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: AlertDialog(
|
||||
title: const Text('أدخل رمز الاستعادة'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'الرمز المرسل إلى بريدك',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'كلمة المرور الجديدة (12 محرفًا على الأقل)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('إلغاء'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (tokenController.text.trim().length < 32 ||
|
||||
passwordController.text.length < 12) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('أدخل رمزًا صالحًا وكلمة مرور من 12 محرفًا على الأقل.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.pop(dialogContext, (
|
||||
tokenController.text.trim(),
|
||||
passwordController.text,
|
||||
));
|
||||
},
|
||||
child: const Text('تغيير كلمة المرور'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
tokenController.dispose();
|
||||
passwordController.dispose();
|
||||
if (reset == null || !mounted) return;
|
||||
try {
|
||||
await api.completePasswordReset(token: reset.$1, newPassword: reset.$2);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('تم تغيير كلمة المرور. سجّل الدخول بها الآن.')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error.toString().replaceFirst('Exception: ', '')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording(VoiceState voice) async {
|
||||
final cubit = context.read<VoiceCubit>();
|
||||
if (voice.status == VoiceStatus.recording) {
|
||||
|
||||
@@ -74,6 +74,8 @@ class AuthenticationTests(unittest.TestCase):
|
||||
("/v1/auth/local-session", "post"),
|
||||
("/v1/auth/register", "post"),
|
||||
("/v1/auth/login", "post"),
|
||||
("/v1/auth/password-reset/request", "post"),
|
||||
("/v1/auth/password-reset/complete", "post"),
|
||||
}
|
||||
missing: list[str] = []
|
||||
for path, operations in app.openapi()["paths"].items():
|
||||
@@ -151,6 +153,78 @@ class AuthenticationTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(expired.status_code, 401)
|
||||
|
||||
def test_password_reset_is_opaque_one_time_and_revokes_existing_sessions(self) -> None:
|
||||
user_id, old_session = self._register("reset@example.test")
|
||||
delivered: list[tuple[str, str | None]] = []
|
||||
|
||||
def capture_delivery(email: str, token: str | None) -> None:
|
||||
delivered.append((email, token))
|
||||
|
||||
with (
|
||||
patch("app.main.password_reset_email.smtp_configured", return_value=True),
|
||||
patch(
|
||||
"app.main.password_reset_email.send_password_reset_email",
|
||||
side_effect=capture_delivery,
|
||||
),
|
||||
):
|
||||
known = self.client.post(
|
||||
"/v1/auth/password-reset/request", json={"email": "Reset@example.test"}
|
||||
)
|
||||
unknown = self.client.post(
|
||||
"/v1/auth/password-reset/request", json={"email": "nobody@example.test"}
|
||||
)
|
||||
|
||||
self.assertEqual(known.status_code, 202, known.text)
|
||||
self.assertEqual(known.json(), unknown.json())
|
||||
self.assertEqual(delivered[0][0], "reset@example.test")
|
||||
self.assertIsNotNone(delivered[0][1])
|
||||
token = delivered[0][1]
|
||||
with database._connect() as connection:
|
||||
stored = connection.execute(
|
||||
"SELECT token_hash FROM password_reset_tokens WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
self.assertNotEqual(stored["token_hash"], token)
|
||||
|
||||
completion = self.client.post(
|
||||
"/v1/auth/password-reset/complete",
|
||||
json={"token": token, "new_password": "a completely new passphrase"},
|
||||
)
|
||||
self.assertEqual(completion.status_code, 200, completion.text)
|
||||
old_session_response = self.client.get(
|
||||
"/v1/auth/me", headers={"Authorization": f"Bearer {old_session}"}
|
||||
)
|
||||
self.assertEqual(old_session_response.status_code, 401)
|
||||
old_password = self.client.post(
|
||||
"/v1/auth/login",
|
||||
json={"email": "reset@example.test", "password": "a long secure passphrase"},
|
||||
)
|
||||
new_password = self.client.post(
|
||||
"/v1/auth/login",
|
||||
json={"email": "reset@example.test", "password": "a completely new passphrase"},
|
||||
)
|
||||
self.assertEqual(old_password.status_code, 401)
|
||||
self.assertEqual(new_password.status_code, 200, new_password.text)
|
||||
reused = self.client.post(
|
||||
"/v1/auth/password-reset/complete",
|
||||
json={"token": token, "new_password": "another new secure passphrase"},
|
||||
)
|
||||
self.assertEqual(reused.status_code, 400)
|
||||
|
||||
def test_password_reset_tokens_expire(self) -> None:
|
||||
user_id, _ = self._register("expired-reset@example.test")
|
||||
token = auth.issue_password_reset("expired-reset@example.test", now=10_000)
|
||||
self.assertIsNotNone(token)
|
||||
self.assertFalse(
|
||||
auth.reset_password(
|
||||
token or "", "a completely new passphrase", now=10_000 + auth.PASSWORD_RESET_LIFETIME_SECONDS
|
||||
)
|
||||
)
|
||||
with database._connect() as connection:
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM password_reset_tokens WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_conversations_are_isolated_between_accounts(self) -> None:
|
||||
first_user, first_token = self._register()
|
||||
_, second_token = self._register()
|
||||
|
||||
Reference in New Issue
Block a user