Add one-time password recovery flow

This commit is contained in:
Hamza Ayed
2026-10-03 13:57:12 +03:00
parent 9945e31e7c
commit ceae822302
9 changed files with 427 additions and 2 deletions
+2 -1
View File
@@ -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` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
+2 -1
View File
@@ -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 على منصات فعلية.
+69
View File
@@ -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:
+9
View File
@@ -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,
+53
View File
@@ -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
View File
@@ -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()