Add local bearer authentication foundation

This commit is contained in:
Hamza Ayed
2026-10-03 00:19:21 +03:00
parent 1782ad1af0
commit 1c1f662850
8 changed files with 505 additions and 39 deletions
+3 -2
View File
@@ -161,8 +161,9 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
### المحادثات والصوت ### المحادثات والصوت
- تحفظ FastAPI المحادثات والرسائل في SQLite محليًا. على Windows يوجد الملف في `%LOCALAPPDATA%\SovereignAI\data\sovereign_ai.sqlite3`، وخارج مجلد المشروع لتجنب مزامنة قاعدة البيانات مع OneDrive. - تحفظ FastAPI المحادثات والرسائل في SQLite محليًا. على Windows يوجد الملف في `%LOCALAPPDATA%\SovereignAI\data\sovereign_ai.sqlite3`، وخارج مجلد المشروع لتجنب مزامنة قاعدة البيانات مع OneDrive.
- لكل سجل محادثة `user_id`، وتُفلتر عمليات القراءة والتعديل والحذف على أساسه. في النسخة المحلية يوفّر FastAPI ملف مستخدم تطوير ثابتًا، وترسل الواجهة معرّفه في ترويسة `X-User-ID`؛ هذا ليس تسجيل دخول أو عزلًا أمنيًا صالحًا للاستضافة العامة. - لكل سجل محادثة `user_id`، وتُفلتر القراءة والتعديل والحذف بهوية جلسة Bearer موثقة؛ لم يعد `X-User-ID` يمنح أي صلاحية. في الوضع المحلي تطلب الواجهة تلقائيًا جلسة للمستخدم المحلي، ولا يصدرها الخادم إلا لاتصال loopback.
- جدول `user_identities` مهيأ لربط المستخدم مستقبلًا بمعرّف مزود مثل Google أو البريد/الهاتف، لكن تدفق تسجيل الدخول لم يُنفذ بعد. عند إضافة مستخدمين حقيقيين يجب استبدال الترويسة بهوية موثقة من جلسة/JWT. - يوفّر API الآن `POST /v1/auth/register` و`POST /v1/auth/login` و`GET /v1/auth/me` و`POST /v1/auth/logout`. كلمات المرور تُخزن بتجزئة PBKDF2 مع salt؛ رمز الجلسة العشوائي يُخزن كـSHA-256 وينتهي بعد 7 أيام ويمكن إلغاؤه. تسجيل الحساب يعمل عبر الـAPI، لكن واجهة الدخول والحفظ الآمن للجلسة على الأجهزة غير مكتملين.
- المصادقة الحالية تحمي سجل المحادثات فقط؛ مسارات الوكيل والملفات والصوت والويب لم تُربط كلها بهوية المستخدم بعد. أبقِ الخدمة على `127.0.0.1` ولا تعرضها على الشبكة؛ يلزم إكمال التفويض لكل المسارات، واجهة الدخول، وحدود محاولات تسجيل الدخول قبل دعم مستخدمين/أجهزة عبر الشبكة.
- رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل. - رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل.
- مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع. - مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع.
- على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة. - على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
+5 -1
View File
@@ -58,7 +58,11 @@
## المرحلة 3 — الهوية والبيانات ## المرحلة 3 — الهوية والبيانات
- استبدال `X-User-ID` التطويري بجلسة موثقة قبل دعم عدة مستخدمين فعليين. - [x] أساس مصادقة محلي في FastAPI: تسجيل/دخول بالبريد وكلمة مرور عبر API، تجزئة PBKDF2 مملحة، جلسات Bearer عشوائية قابلة للإلغاء وتنتهي بعد 7 أيام، وترحيل SQLite يحافظ على هويات OAuth القديمة. `X-User-ID` لم يعد يخول الوصول لسجل المحادثات؛ الجلسة المحلية التلقائية لا تصدر إلا لعميل loopback. اختبارات المصادقة والعزل والترحيل: 7 ناجحة (2026-10-03).
- [x] ربط CRUD المحادثات والتقييم بهوية الجلسة، والتحقق من أن حسابًا ثانيًا لا يقرأ محادثة الحساب الأول.
- [ ] قبل دعم عدة مستخدمين أو أي ربط شبكي: فرض المصادقة والتفويض على كل مسارات المحادثة/النموذج والوكيل والملفات والمعرفة والبحث والصوت، وربط الفهرس وسجل التدقيق بمالك المستخدم بدل هوية محلية ثابتة.
- [ ] بناء شاشة إنشاء الحساب/الدخول والخروج، وتخزين الرموز في مخزن آمن مناسب لكل منصة؛ حاليًا عميل Flutter يطلب جلسة محلية تلقائيًا، ودوال الحساب غير موصولة بواجهة ولا تحفظ رمزها بعد إغلاق التطبيق.
- [ ] إضافة حدود لمحاولات الدخول/التسجيل وتدفق استعادة كلمة المرور، ثم اختبارات تفويض شاملة لكل المسارات.
- تصميم بيانات المستخدمين والمحادثات والمرفقات ونسخ الإجابات مع ملكية واضحة وفهارس وترحيلات قاعدة بيانات. - تصميم بيانات المستخدمين والمحادثات والمرفقات ونسخ الإجابات مع ملكية واضحة وفهارس وترحيلات قاعدة بيانات.
- SQLite مناسب لنسخة محلية أحادية الجهاز. عند تشغيل خدمة لعدة مستخدمين/أجهزة، ننتقل إلى PostgreSQL، مع نسخ احتياطية وسياسة حذف وتصدير. - SQLite مناسب لنسخة محلية أحادية الجهاز. عند تشغيل خدمة لعدة مستخدمين/أجهزة، ننتقل إلى PostgreSQL، مع نسخ احتياطية وسياسة حذف وتصدير.
- تخزين الملفات الكبيرة في مساحة ملفات منظّمة، وحفظ بياناتها الوصفية ومراجعها في قاعدة البيانات، لا في سجل الرسائل ككتل ضخمة. - تخزين الملفات الكبيرة في مساحة ملفات منظّمة، وحفظ بياناتها الوصفية ومراجعها في قاعدة البيانات، لا في سجل الرسائل ككتل ضخمة.
+139
View File
@@ -0,0 +1,139 @@
"""Local password accounts and opaque, revocable bearer sessions."""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import re
import secrets
import sqlite3
import time
from uuid import uuid4
from app import database
PASSWORD_ITERATIONS = 310_000
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
_DUMMY_SALT = bytes.fromhex("4f2bc994cb56a7a98b253a615fb57d2c")
_DUMMY_DIGEST = hashlib.pbkdf2_hmac(
"sha256", b"constant-time dummy password", _DUMMY_SALT, PASSWORD_ITERATIONS
)
def normalize_email(email: str) -> str:
normalized = email.strip().casefold()
if len(normalized) > 254 or not _EMAIL_PATTERN.fullmatch(normalized):
raise ValueError("أدخل بريدًا إلكترونيًا صالحًا.")
return normalized
def _hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, PASSWORD_ITERATIONS
)
return "$".join(
(
"pbkdf2_sha256",
str(PASSWORD_ITERATIONS),
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
)
def _verify_password(password: str, encoded: str | None) -> bool:
if encoded is None:
salt, expected, iterations = _DUMMY_SALT, _DUMMY_DIGEST, PASSWORD_ITERATIONS
else:
try:
algorithm, rounds, salt_text, digest_text = encoded.split("$", 3)
iterations = int(rounds)
if algorithm != "pbkdf2_sha256" or not 100_000 <= iterations <= 1_000_000:
return False
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_text.encode("ascii"))
except (ValueError, UnicodeError, binascii.Error):
return False
actual = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations
)
return hmac.compare_digest(actual, expected)
def create_account(email: str, password: str) -> str:
normalized = normalize_email(email)
if len(password) < 12 or len(password) > 256:
raise ValueError("يجب أن تتراوح كلمة المرور بين 12 و256 محرفًا.")
user_id = str(uuid4())
password_hash = _hash_password(password)
try:
with database._connect() as connection:
connection.execute("INSERT INTO users(id) VALUES (?)", (user_id,))
connection.execute(
"""INSERT INTO user_identities(user_id,provider,provider_subject,email,password_hash)
VALUES(?, 'password', ?, ?, ?)""",
(user_id, normalized, normalized, password_hash),
)
except sqlite3.IntegrityError as exc:
raise ValueError("يوجد حساب مسجل بهذا البريد الإلكتروني.") from exc
return user_id
def authenticate(email: str, password: str) -> tuple[str, str] | None:
normalized = normalize_email(email)
with database._connect() as connection:
row = connection.execute(
"""SELECT user_id,password_hash FROM user_identities
WHERE provider='password' AND provider_subject=?""",
(normalized,),
).fetchone()
password_hash = row["password_hash"] if row is not None else None
valid = _verify_password(password, password_hash)
if row is None or not valid:
return None
return row["user_id"], normalized
def issue_session(user_id: str) -> tuple[str, int]:
token = secrets.token_urlsafe(32)
expires_at = int(time.time()) + SESSION_LIFETIME_SECONDS
token_hash = hashlib.sha256(token.encode("ascii")).hexdigest()
with database._connect() as connection:
connection.execute("DELETE FROM auth_sessions WHERE expires_at <= ?", (int(time.time()),))
connection.execute(
"INSERT INTO auth_sessions(token_hash,user_id,expires_at) VALUES(?,?,?)",
(token_hash, user_id, expires_at),
)
return token, expires_at
def resolve_session(token: str) -> str | None:
if not token or len(token) > 256:
return None
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
now = int(time.time())
with database._connect() as connection:
row = connection.execute(
"SELECT user_id FROM auth_sessions WHERE token_hash=? AND expires_at>?",
(token_hash, now),
).fetchone()
return row["user_id"] if row is not None else None
def revoke_session(token: str) -> None:
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
with database._connect() as connection:
connection.execute("DELETE FROM auth_sessions WHERE token_hash=?", (token_hash,))
def account_email(user_id: str) -> str | None:
with database._connect() as connection:
row = connection.execute(
"SELECT email FROM user_identities WHERE user_id=? AND provider='password'",
(user_id,),
).fetchone()
return row["email"] if row is not None else None
+15
View File
@@ -57,10 +57,20 @@ def initialize_database() -> None:
provider TEXT NOT NULL, provider TEXT NOT NULL,
provider_subject TEXT NOT NULL, provider_subject TEXT NOT NULL,
email TEXT, email TEXT,
password_hash TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(provider, provider_subject) UNIQUE(provider, provider_subject)
); );
CREATE TABLE IF NOT EXISTS auth_sessions (
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_auth_sessions_user
ON auth_sessions(user_id, expires_at);
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -123,6 +133,11 @@ def initialize_database() -> None:
connection.execute( connection.execute(
"ALTER TABLE messages ADD COLUMN selected_version INTEGER NOT NULL DEFAULT 0" "ALTER TABLE messages ADD COLUMN selected_version INTEGER NOT NULL DEFAULT 0"
) )
identity_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(user_identities)")
}
if "password_hash" not in identity_columns:
connection.execute("ALTER TABLE user_identities ADD COLUMN password_hash TEXT")
def ensure_user(user_id: str) -> None: def ensure_user(user_id: str) -> None:
+99 -21
View File
@@ -15,14 +15,16 @@ from uuid import UUID, uuid4
from urllib.parse import urljoin, urlsplit from urllib.parse import urljoin, urlsplit
import httpx import httpx
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from app import database from app import database
from app import auth
from app.model_provider import get_model_provider from app.model_provider import get_model_provider
from app import workspace from app import workspace
from app import skills from app import skills
@@ -39,6 +41,7 @@ from app.local_ocr import LocalOCRError, recognize_image_text
from app.web_search import parse_duckduckgo_results from app.web_search import parse_duckduckgo_results
logger = logging.getLogger("sovereignai.audio") logger = logging.getLogger("sovereignai.audio")
_bearer_scheme = HTTPBearer(auto_error=False)
MAX_ATTACHMENT_BYTES = 256 * 1024 MAX_ATTACHMENT_BYTES = 256 * 1024
MAX_PDF_ATTACHMENT_BYTES = 8 * 1024 * 1024 MAX_PDF_ATTACHMENT_BYTES = 8 * 1024 * 1024
MAX_ATTACHMENT_TOTAL_BYTES = 16 * 1024 * 1024 MAX_ATTACHMENT_TOTAL_BYTES = 16 * 1024 * 1024
@@ -54,7 +57,7 @@ app.add_middleware(
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
allow_credentials=False, allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE"], allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "X-User-ID"], allow_headers=["Authorization", "Content-Type"],
expose_headers=["X-Agent-Audit-ID", "X-Request-ID"], expose_headers=["X-Agent-Audit-ID", "X-Request-ID"],
) )
@@ -254,6 +257,11 @@ class ConversationWrite(BaseModel):
messages: list[StoredMessage] = Field(min_length=1, max_length=2000) messages: list[StoredMessage] = Field(min_length=1, max_length=2000)
class PasswordCredentials(BaseModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=12, max_length=256)
class WebReadRequest(BaseModel): class WebReadRequest(BaseModel):
url: str = Field(min_length=8, max_length=2048, description="رابط صفحة ويب عامة تريد تحليلها") url: str = Field(min_length=8, max_length=2048, description="رابط صفحة ويب عامة تريد تحليلها")
question: str = Field(default="لخّص محتوى الصفحة وأهم نقاطها.", min_length=1, max_length=2000) question: str = Field(default="لخّص محتوى الصفحة وأهم نقاطها.", min_length=1, max_length=2000)
@@ -382,11 +390,23 @@ async def _read_public_page(raw_url: str) -> tuple[str, str, str]:
raise HTTPException(status_code=502, detail="تعذر الوصول إلى الموقع؛ تحقق من الإنترنت أو من إعدادات الموقع.") from exc raise HTTPException(status_code=502, detail="تعذر الوصول إلى الموقع؛ تحقق من الإنترنت أو من إعدادات الموقع.") from exc
def validate_user_id(value: str) -> str: def get_authenticated_user_id(
try: credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
return str(UUID(value)) ) -> str:
except ValueError as exc: if credentials is None:
raise HTTPException(status_code=400, detail="X-User-ID must be a UUID.") from exc raise HTTPException(
status_code=401,
detail="سجّل الدخول للوصول إلى سجل المحادثات.",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = auth.resolve_session(credentials.credentials.strip())
if user_id is None:
raise HTTPException(
status_code=401,
detail="انتهت الجلسة أو أُلغيت؛ سجّل الدخول مجددًا.",
headers={"WWW-Authenticate": "Bearer"},
)
return user_id
def validate_conversation_id(value: str) -> str: def validate_conversation_id(value: str) -> str:
@@ -1113,11 +1133,74 @@ async def analyze_images(
} }
@app.get("/v1/local-user") def _auth_response(user_id: str, email: str | None, mode: str) -> dict[str, Any]:
def get_local_user() -> dict[str, str]: token, expires_at = auth.issue_session(user_id)
"""Return the single local development profile; authentication comes later.""" return {
"access_token": token,
"token_type": "bearer",
"expires_at": expires_at,
"user": {"id": user_id, "email": email, "mode": mode},
}
@app.post("/v1/auth/local-session")
def create_local_session(request: Request) -> dict[str, Any]:
"""Issue a single-user token only to a client connected through loopback."""
client_host = request.client.host if request.client is not None else ""
try:
is_loopback = ipaddress.ip_address(client_host).is_loopback
except ValueError:
is_loopback = False
if not is_loopback:
raise HTTPException(status_code=403, detail="الجلسة المحلية متاحة من هذا الجهاز فقط.")
database.ensure_user(database.LOCAL_USER_ID) database.ensure_user(database.LOCAL_USER_ID)
return {"user_id": database.LOCAL_USER_ID, "mode": "local-development"} return _auth_response(database.LOCAL_USER_ID, None, "local-single-user")
@app.post("/v1/auth/register", status_code=201)
def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
try:
user_id = auth.create_account(credentials.email, credentials.password)
email = auth.normalize_email(credentials.email)
except ValueError as exc:
status = 409 if "مسجل" in str(exc) else 422
raise HTTPException(status_code=status, detail=str(exc)) from exc
return _auth_response(user_id, email, "account")
@app.post("/v1/auth/login")
def login_account(credentials: PasswordCredentials) -> dict[str, Any]:
try:
account = auth.authenticate(credentials.email, credentials.password)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if account is None:
raise HTTPException(status_code=401, detail="البريد الإلكتروني أو كلمة المرور غير صحيحة.")
user_id, email = account
return _auth_response(user_id, email, "account")
@app.get("/v1/auth/me")
def current_account(user_id: str = Depends(get_authenticated_user_id)) -> dict[str, Any]:
email = auth.account_email(user_id)
return {
"user": {
"id": user_id,
"email": email,
"mode": "account" if email else "local-single-user",
}
}
@app.post("/v1/auth/logout")
def logout_account(
user_id: str = Depends(get_authenticated_user_id),
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> dict[str, str]:
del user_id
if credentials is not None:
auth.revoke_session(credentials.credentials.strip())
return {"status": "logged_out"}
def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]: def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
@@ -1177,9 +1260,8 @@ async def chat_stream(request: ChatRequest) -> StreamingResponse:
@app.get("/v1/conversations") @app.get("/v1/conversations")
def list_user_conversations( def list_user_conversations(
x_user_id: str = Header(alias="X-User-ID"), user_id: str = Depends(get_authenticated_user_id),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
user_id = validate_user_id(x_user_id)
database.ensure_user(user_id) database.ensure_user(user_id)
return database.list_conversations(user_id) return database.list_conversations(user_id)
@@ -1187,9 +1269,8 @@ def list_user_conversations(
@app.get("/v1/conversations/{conversation_id}") @app.get("/v1/conversations/{conversation_id}")
def read_user_conversation( def read_user_conversation(
conversation_id: str, conversation_id: str,
x_user_id: str = Header(alias="X-User-ID"), user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]: ) -> dict[str, Any]:
user_id = validate_user_id(x_user_id)
result = database.get_conversation( result = database.get_conversation(
user_id, validate_conversation_id(conversation_id) user_id, validate_conversation_id(conversation_id)
) )
@@ -1203,9 +1284,8 @@ def rate_assistant_answer(
conversation_id: str, conversation_id: str,
message_index: int, message_index: int,
request: FeedbackRequest, request: FeedbackRequest,
x_user_id: str = Header(alias="X-User-ID"), user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]: ) -> dict[str, Any]:
user_id = validate_user_id(x_user_id)
conversation_id = validate_conversation_id(conversation_id) conversation_id = validate_conversation_id(conversation_id)
conversation = database.get_conversation(user_id, conversation_id) conversation = database.get_conversation(user_id, conversation_id)
if conversation is None: if conversation is None:
@@ -1230,9 +1310,8 @@ def rate_assistant_answer(
def write_user_conversation( def write_user_conversation(
conversation_id: str, conversation_id: str,
request: ConversationWrite, request: ConversationWrite,
x_user_id: str = Header(alias="X-User-ID"), user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, str]: ) -> dict[str, str]:
user_id = validate_user_id(x_user_id)
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
try: try:
database.save_conversation( database.save_conversation(
@@ -1250,9 +1329,8 @@ def write_user_conversation(
@app.delete("/v1/conversations/{conversation_id}") @app.delete("/v1/conversations/{conversation_id}")
def remove_user_conversation( def remove_user_conversation(
conversation_id: str, conversation_id: str,
x_user_id: str = Header(alias="X-User-ID"), user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, str]: ) -> dict[str, str]:
user_id = validate_user_id(x_user_id)
deleted = database.delete_conversation( deleted = database.delete_conversation(
user_id, validate_conversation_id(conversation_id) user_id, validate_conversation_id(conversation_id)
) )
@@ -21,8 +21,8 @@ class ApiRepository {
ApiRepository({required String baseUrl}) : _baseUrl = baseUrl; ApiRepository({required String baseUrl}) : _baseUrl = baseUrl;
String _baseUrl; String _baseUrl;
String? _cachedUserId; String? _accessToken;
Future<String>? _loadingUserId; Future<String>? _loadingLocalSession;
http.Client? _activeRequestClient; http.Client? _activeRequestClient;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
@@ -33,8 +33,11 @@ class ApiRepository {
String newId() => _uuid.v4(); String newId() => _uuid.v4();
void setBaseUrl(String value) => void setBaseUrl(String value) {
_baseUrl = value.replaceFirst(RegExp(r'/+$'), ''); _baseUrl = value.replaceFirst(RegExp(r'/+$'), '');
_accessToken = null;
_loadingLocalSession = null;
}
Future<String> getModelName() async { Future<String> getModelName() async {
final response = await http.get(Uri.parse('$_baseUrl/health')); final response = await http.get(Uri.parse('$_baseUrl/health'));
@@ -60,25 +63,75 @@ class ApiRepository {
}).toList(); }).toList();
} }
Future<String> _userId() async { Future<String> _localSessionToken() async {
final cached = _cachedUserId; final cached = _accessToken;
if (cached != null) return cached; if (cached != null) return cached;
return _loadingUserId ??= _loadLocalUserId(); final existing = _loadingLocalSession;
if (existing != null) return existing;
final pending = _loadLocalSessionToken();
_loadingLocalSession = pending;
try {
return await pending;
} finally {
_loadingLocalSession = null;
}
} }
Future<String> _loadLocalUserId() async { Future<String> _loadLocalSessionToken() async {
final response = await http.get(Uri.parse('$_baseUrl/v1/local-user')); final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/local-session'),
headers: const {'Content-Type': 'application/json'},
body: '{}',
);
_checkStatus(response); _checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
_cachedUserId = data['user_id'] as String; _accessToken = data['access_token'] as String;
return _cachedUserId!; return _accessToken!;
} }
Future<Map<String, String>> _userHeaders() async => { Future<Map<String, String>> _userHeaders() async => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-User-ID': await _userId(), 'Authorization': 'Bearer ${await _localSessionToken()}',
}; };
Future<Map<String, dynamic>> registerAccount({
required String email,
required String password,
}) => _authenticateAccount('register', email, password);
Future<Map<String, dynamic>> login({
required String email,
required String password,
}) => _authenticateAccount('login', email, password);
Future<Map<String, dynamic>> _authenticateAccount(
String action,
String email,
String password,
) async {
final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/$action'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = data['access_token'] as String;
return data;
}
Future<void> logout() async {
final token = _accessToken;
if (token == null) return;
final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
_checkStatus(response);
_accessToken = null;
_loadingLocalSession = null;
}
void _checkStatus(http.Response response) { void _checkStatus(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_apiError(response)); throw Exception(_apiError(response));
+157
View File
@@ -0,0 +1,157 @@
import os
import unittest
from pathlib import Path
from uuid import uuid4
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
os.environ.setdefault(
"SOVEREIGNAI_DATA_DIR", str(_PROJECT_ROOT / ".test-runtime" / "auth-tests")
)
from fastapi.testclient import TestClient
from starlette.requests import Request
from app import auth, database
from app.main import app, create_local_session
class AuthenticationTests(unittest.TestCase):
def setUp(self) -> None:
self.client = TestClient(app)
self.created_users: list[str] = []
def tearDown(self) -> None:
with database._connect() as connection:
connection.executemany(
"DELETE FROM users WHERE id=?",
[(user_id,) for user_id in self.created_users],
)
def _register(self, email: str | None = None) -> tuple[str, str]:
account_email = email or f"{uuid4().hex}@example.test"
response = self.client.post(
"/v1/auth/register",
json={"email": account_email, "password": "a long secure passphrase"},
)
self.assertEqual(response.status_code, 201, response.text)
data = response.json()
self.created_users.append(data["user"]["id"])
return data["user"]["id"], data["access_token"]
def test_conversation_api_rejects_missing_or_forged_development_identity(self) -> None:
response = self.client.get(
"/v1/conversations",
headers={"X-User-ID": database.LOCAL_USER_ID},
)
self.assertEqual(response.status_code, 401, response.text)
self.assertEqual(response.headers["www-authenticate"], "Bearer")
def test_register_login_me_and_logout_use_revocable_sessions(self) -> None:
user_id, token = self._register("User@Example.Test")
me = self.client.get(
"/v1/auth/me", headers={"Authorization": f"Bearer {token}"}
)
self.assertEqual(me.status_code, 200, me.text)
self.assertEqual(me.json()["user"], {
"id": user_id,
"email": "user@example.test",
"mode": "account",
})
with database._connect() as connection:
stored = connection.execute(
"SELECT password_hash FROM user_identities WHERE user_id=?", (user_id,)
).fetchone()
self.assertNotIn("a long secure passphrase", stored["password_hash"])
self.assertEqual(
connection.execute(
"SELECT COUNT(*) FROM auth_sessions WHERE user_id=?", (user_id,)
).fetchone()[0],
1,
)
login = self.client.post(
"/v1/auth/login",
json={"email": "USER@example.test", "password": "a long secure passphrase"},
)
self.assertEqual(login.status_code, 200, login.text)
wrong_password = self.client.post(
"/v1/auth/login",
json={"email": "user@example.test", "password": "a different wrong phrase"},
)
self.assertEqual(wrong_password.status_code, 401)
logout = self.client.post(
"/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}
)
self.assertEqual(logout.status_code, 200, logout.text)
expired = self.client.get(
"/v1/auth/me", headers={"Authorization": f"Bearer {token}"}
)
self.assertEqual(expired.status_code, 401)
def test_conversations_are_isolated_between_accounts(self) -> None:
first_user, first_token = self._register()
_, second_token = self._register()
conversation_id = str(uuid4())
saved = self.client.put(
f"/v1/conversations/{conversation_id}",
headers={"Authorization": f"Bearer {first_token}"},
json={
"title": "private",
"messages": [{"role": "user", "content": "private message"}],
},
)
self.assertEqual(saved.status_code, 200, saved.text)
read = self.client.get(
f"/v1/conversations/{conversation_id}",
headers={"Authorization": f"Bearer {second_token}"},
)
self.assertEqual(read.status_code, 404)
first_list = self.client.get(
"/v1/conversations", headers={"Authorization": f"Bearer {first_token}"}
)
second_list = self.client.get(
"/v1/conversations", headers={"Authorization": f"Bearer {second_token}"}
)
self.assertEqual(first_list.json()[0]["id"], conversation_id)
self.assertEqual(second_list.json(), [])
self.assertTrue(first_user)
def test_password_account_rejects_duplicate_email(self) -> None:
email = f"{uuid4().hex}@example.test"
self._register(email)
duplicate = self.client.post(
"/v1/auth/register",
json={"email": email.upper(), "password": "a long secure passphrase"},
)
self.assertEqual(duplicate.status_code, 409, duplicate.text)
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)
request = Request(
{
"type": "http",
"method": "POST",
"path": "/v1/auth/local-session",
"headers": [],
"client": ("127.0.0.1", 43210),
"server": ("127.0.0.1", 8000),
"scheme": "http",
"query_string": b"",
}
)
token_response = create_local_session(request)
self.assertEqual(token_response["user"]["id"], database.LOCAL_USER_ID)
authenticated = self.client.get(
"/v1/auth/me",
headers={"Authorization": f"Bearer {token_response['access_token']}"},
)
self.assertEqual(authenticated.status_code, 200, authenticated.text)
if __name__ == "__main__":
unittest.main()
@@ -5,7 +5,7 @@ from uuid import uuid4
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import database from app import auth, database
from app.main import app from app.main import app
@@ -24,6 +24,15 @@ class ConversationVersionMigrationTests(unittest.TestCase):
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE user_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
email TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(provider, provider_subject)
);
CREATE TABLE conversations ( CREATE TABLE conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -39,6 +48,8 @@ class ConversationVersionMigrationTests(unittest.TestCase):
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
INSERT INTO users(id) VALUES ('00000000-0000-4000-8000-000000000001'); INSERT INTO users(id) VALUES ('00000000-0000-4000-8000-000000000001');
INSERT INTO user_identities(user_id,provider,provider_subject,email)
VALUES ('00000000-0000-4000-8000-000000000001','google','subject-1','old@example.test');
INSERT INTO conversations(id, user_id, title, created_at, updated_at) INSERT INTO conversations(id, user_id, title, created_at, updated_at)
VALUES ('conversation-1', '00000000-0000-4000-8000-000000000001', VALUES ('conversation-1', '00000000-0000-4000-8000-000000000001',
'قديم', '2026-01-01', '2026-01-01'); 'قديم', '2026-01-01', '2026-01-01');
@@ -58,6 +69,13 @@ class ConversationVersionMigrationTests(unittest.TestCase):
def test_old_history_migrates_and_answer_versions_round_trip(self) -> None: def test_old_history_migrates_and_answer_versions_round_trip(self) -> None:
database.initialize_database() database.initialize_database()
with database._connect() as connection:
identity = connection.execute(
"SELECT provider_subject,email,password_hash FROM user_identities WHERE provider='google'"
).fetchone()
self.assertEqual(identity["provider_subject"], "subject-1")
self.assertEqual(identity["email"], "old@example.test")
self.assertIsNone(identity["password_hash"])
old_conversation = database.get_conversation( old_conversation = database.get_conversation(
"00000000-0000-4000-8000-000000000001", "conversation-1" "00000000-0000-4000-8000-000000000001", "conversation-1"
) )
@@ -90,6 +108,7 @@ class ConversationVersionMigrationTests(unittest.TestCase):
def test_api_saves_and_returns_selected_answer_version(self) -> None: def test_api_saves_and_returns_selected_answer_version(self) -> None:
user_id = "00000000-0000-4000-8000-000000000001" user_id = "00000000-0000-4000-8000-000000000001"
token, _ = auth.issue_session(user_id)
conversation_id = "00000000-0000-4000-8000-000000000099" conversation_id = "00000000-0000-4000-8000-000000000099"
payload = { payload = {
"title": "API version test", "title": "API version test",
@@ -106,13 +125,13 @@ class ConversationVersionMigrationTests(unittest.TestCase):
with TestClient(app) as client: with TestClient(app) as client:
saved = client.put( saved = client.put(
f"/v1/conversations/{conversation_id}", f"/v1/conversations/{conversation_id}",
headers={"X-User-ID": user_id}, headers={"Authorization": f"Bearer {token}"},
json=payload, json=payload,
) )
self.assertEqual(saved.status_code, 200, saved.text) self.assertEqual(saved.status_code, 200, saved.text)
loaded = client.get( loaded = client.get(
f"/v1/conversations/{conversation_id}", f"/v1/conversations/{conversation_id}",
headers={"X-User-ID": user_id}, headers={"Authorization": f"Bearer {token}"},
) )
self.assertEqual(loaded.status_code, 200, loaded.text) self.assertEqual(loaded.status_code, 200, loaded.text)
assistant = loaded.json()["messages"][1] assistant = loaded.json()["messages"][1]