diff --git a/.env.example b/.env.example index 9bf4109..6d5d59a 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_MODEL=deepseek-v4-flash DEFAULT_SYSTEM_PROMPT=You are a helpful assistant. CHAT_DATA_DIR=data -API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret-key"} +USER_DB_PATH=data/users.db MAX_TOOL_ROUNDS=5 MAX_TOOL_CALLS_PER_TURN=10 -TOOL_TIMEOUT_SECONDS=5 +TOOL_TIMEOUT_SECONDS=5 \ No newline at end of file diff --git a/README.md b/README.md index 83669c1..9f2a14d 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ ## 代码结构 - `routes.py`:集中定义所有 HTTP 路由。 -- `auth.py`:校验 API key 白名单并识别当前用户。 +- `auth.py`:根据 `user_id` 识别当前用户,校验请求 `X-API-Key`。 - `schemas.py`:集中定义 API 请求和响应结构。 +- `users.py`:基于 SQLite 的用户注册、登录与 API Key 持久化。 - `domain.py`:定义会话、消息和工具调用的持久化模型。 - `tools.py`:集中定义工具注册表、参数结构和工具函数。 - `service.py`:处理对话、Agent 循环和业务规则。 @@ -23,12 +24,27 @@ uv run uvicorn main:app --reload 服务默认运行在 `http://127.0.0.1:8000`,交互式 API 文档位于 `/docs`。 -服务会自动加载项目根目录的 `.env`,已有系统环境变量优先级更高。可配置 `DEEPSEEK_BASE_URL`、`DEEPSEEK_MODEL`、`DEFAULT_SYSTEM_PROMPT`、`CHAT_DATA_DIR` 和工具调用限制,完整示例见 `.env.example`。第一版应只使用一个 Uvicorn worker。 +服务会自动加载项目根目录的 `.env`,已有系统环境变量优先级更高。可配置 `DEEPSEEK_BASE_URL`、`DEEPSEEK_MODEL`、`DEFAULT_SYSTEM_PROMPT`、`CHAT_DATA_DIR`、`USER_DB_PATH` 和工具调用限制,完整示例见 `.env.example`。第一版应只使用一个 Uvicorn worker。 -所有业务接口都必须通过 `X-API-Key` 请求头提供访问密钥。白名单使用用户到密钥的 JSON 映射: +用户名、密码哈希、UUID 及 API Key 保存在 SQLite 中(默认路径 `USER_DB_PATH=data/users.db`,随服务启动自动建表)。所有业务接口都必须通过 `X-API-Key` 请求头提供访问密钥,服务端从数据库中查找比对以识别当前用户。 -```text -API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret-key"} +## 注册与登录 + +注册用户(用户名已存在返回 `409`): + +```bash +curl -X POST http://127.0.0.1:8000/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"username":"小明","password":"your-password"}' +``` + +用用户名、密码换取持久化的 API Key(凭据错误返回 `401`): + +```bash +curl -X POST http://127.0.0.1:8000/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"小明","password":"your-password"}' +# => {"user_id":"...","api_key":"YOUR_API_KEY"} ``` ## 使用 @@ -37,7 +53,7 @@ API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret ```bash curl -X POST http://127.0.0.1:8000/sessions \ - -H 'X-API-Key: replace-with-a-secret-key' \ + -H 'X-API-Key: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"system_prompt":"你是一个简洁的中文助手。"}' ``` @@ -46,7 +62,7 @@ curl -X POST http://127.0.0.1:8000/sessions \ ```bash curl -X POST http://127.0.0.1:8000/sessions/SESSION_ID/messages \ - -H 'X-API-Key: replace-with-a-secret-key' \ + -H 'X-API-Key: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"content":"你好,请记住我的名字是小明。"}' ``` @@ -63,14 +79,14 @@ API 返回及存储的每条消息都包含 UTC `created_at`。只有模型完 ```bash curl http://127.0.0.1:8000/sessions/SESSION_ID/messages \ - -H 'X-API-Key: replace-with-a-secret-key' + -H 'X-API-Key: YOUR_API_KEY' ``` 查询当前用户的累计 API 调用次数和 token 用量: ```bash curl http://127.0.0.1:8000/usage \ - -H 'X-API-Key: replace-with-a-secret-key' + -H 'X-API-Key: YOUR_API_KEY' ``` 每个会话 JSON 顶层保存所属 `user_id`、成功聊天次数及累计 token。`/usage` 会扫描并汇总当前用户的全部会话,不使用独立统计文件。创建会话、查询历史和查询用量不计入 `api_calls`。 diff --git a/app.py b/app.py index e036264..5eba108 100644 --- a/app.py +++ b/app.py @@ -10,11 +10,13 @@ from routes import router from service import ChatService from storage import JsonSessionStorage from tools import ToolRegistry +from users import UserStore def create_app( settings: Settings | None = None, client: AsyncOpenAI | None = None, + user_store: UserStore | None = None, ) -> FastAPI: resolved_settings = settings or Settings.from_env() @@ -29,6 +31,10 @@ def create_app( base_url=resolved_settings.base_url, ) + store = user_store or UserStore(resolved_settings.user_db_path) + await store.init() + + app.state.user_store = store app.state.chat_service = ChatService( storage=JsonSessionStorage(resolved_settings.data_dir), client=resolved_client, @@ -38,9 +44,7 @@ def create_app( max_tool_rounds=resolved_settings.max_tool_rounds, max_tool_calls_per_turn=resolved_settings.max_tool_calls_per_turn, ) - app.state.authenticator = ApiKeyAuthenticator( - resolved_settings.api_key_whitelist - ) + app.state.authenticator = ApiKeyAuthenticator(store) yield if client is None: @@ -51,4 +55,4 @@ def create_app( return app -app = create_app() +app = create_app() \ No newline at end of file diff --git a/auth.py b/auth.py index 6a30c5d..f308f5b 100644 --- a/auth.py +++ b/auth.py @@ -1,31 +1,19 @@ -from dataclasses import dataclass -import hmac from typing import Annotated from fastapi import Depends, HTTPException, Request, Security, status from fastapi.security import APIKeyHeader +from users import AuthenticatedUser, UserStore + api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) -@dataclass(frozen=True, slots=True) -class AuthenticatedUser: - user_id: str - - class ApiKeyAuthenticator: - def __init__(self, whitelist: dict[str, str]) -> None: - if not whitelist: - raise ValueError("API_KEY_WHITELIST must contain at least one user") - self._whitelist = whitelist.copy() + def __init__(self, user_store: UserStore) -> None: + self._user_store = user_store - def authenticate(self, api_key: str | None) -> AuthenticatedUser | None: - if not api_key: - return None - for user_id, allowed_key in self._whitelist.items(): - if hmac.compare_digest(api_key, allowed_key): - return AuthenticatedUser(user_id=user_id) - return None + async def authenticate(self, api_key: str | None) -> AuthenticatedUser | None: + return await self._user_store.authenticate(api_key) async def require_api_key( @@ -33,7 +21,7 @@ async def require_api_key( api_key: Annotated[str | None, Security(api_key_header)], ) -> AuthenticatedUser: authenticator: ApiKeyAuthenticator = request.app.state.authenticator - user = authenticator.authenticate(api_key) + user = await authenticator.authenticate(api_key) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -42,4 +30,4 @@ async def require_api_key( return user -CurrentUser = Annotated[AuthenticatedUser, Depends(require_api_key)] +CurrentUser = Annotated[AuthenticatedUser, Depends(require_api_key)] \ No newline at end of file diff --git a/config.py b/config.py index 6e859df..7215171 100644 --- a/config.py +++ b/config.py @@ -1,8 +1,6 @@ from dataclasses import dataclass -import json -from pathlib import Path import os -from uuid import UUID +from pathlib import Path from dotenv import load_dotenv @@ -15,7 +13,7 @@ class Settings: model: str default_system_prompt: str data_dir: Path - api_key_whitelist: dict[str, str] + user_db_path: Path max_tool_rounds: int = 5 max_tool_calls_per_turn: int = 10 tool_timeout_seconds: float = 5.0 @@ -23,6 +21,7 @@ class Settings: @classmethod def from_env(cls) -> "Settings": load_dotenv(dotenv_path=Path.cwd() / ".env", override=False) + data_dir = Path(os.getenv("CHAT_DATA_DIR", "data")) return cls( port=int(os.getenv("SERVICE_PORT", "8000")), api_key=os.getenv("DEEPSEEK_API_KEY"), @@ -31,38 +30,11 @@ class Settings: default_system_prompt=os.getenv( "DEFAULT_SYSTEM_PROMPT", "You are a helpful assistant." ), - data_dir=Path(os.getenv("CHAT_DATA_DIR", "data")), - api_key_whitelist=_parse_api_key_whitelist( - os.getenv("API_KEY_WHITELIST", "{}") - ), + data_dir=data_dir, + user_db_path=Path(os.getenv("USER_DB_PATH", str(data_dir / "users.db"))), max_tool_rounds=int(os.getenv("MAX_TOOL_ROUNDS", "5")), max_tool_calls_per_turn=int( os.getenv("MAX_TOOL_CALLS_PER_TURN", "10") ), tool_timeout_seconds=float(os.getenv("TOOL_TIMEOUT_SECONDS", "5")), - ) - - -def _parse_api_key_whitelist(value: str) -> dict[str, str]: - try: - parsed = json.loads(value) - except json.JSONDecodeError as exc: - raise ValueError("API_KEY_WHITELIST must be valid JSON") from exc - if not isinstance(parsed, dict): - raise ValueError("API_KEY_WHITELIST must map user UUIDs to API keys") - - normalized: dict[str, str] = {} - for user_id, api_key in parsed.items(): - if not isinstance(user_id, str) or not isinstance(api_key, str) or not api_key: - raise ValueError("API_KEY_WHITELIST must map user UUIDs to API keys") - try: - normalized_user_id = str(UUID(user_id)) - except ValueError as exc: - raise ValueError("API_KEY_WHITELIST user IDs must be valid UUIDs") from exc - if normalized_user_id in normalized: - raise ValueError("API_KEY_WHITELIST contains duplicate user UUIDs") - normalized[normalized_user_id] = api_key - - if len(set(normalized.values())) != len(normalized): - raise ValueError("API keys in API_KEY_WHITELIST must be unique") - return normalized + ) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ebb81e1..7ed0b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "A minimal persistent DeepSeek chat API" readme = "README.md" requires-python = ">=3.14" dependencies = [ + "aiosqlite>=0.20.0", + "bcrypt>=4.0.0", "fastapi>=0.115.0", "httpx[socks]>=0.28.0", "openai>=1.55.0", diff --git a/routes.py b/routes.py index f4eb3ff..3afeeb6 100644 --- a/routes.py +++ b/routes.py @@ -4,6 +4,10 @@ from auth import CurrentUser from schemas import ( CreateSessionRequest, CreateSessionResponse, + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, SendMessageRequest, SendMessageResponse, SessionHistoryResponse, @@ -11,6 +15,12 @@ from schemas import ( ) from service import ChatProviderError, ChatService from storage import SessionNotFoundError, SessionStorageError +from users import ( + InvalidCredentialsError, + UserStore, + UserStoreError, + UsernameExistsError, +) router = APIRouter() @@ -20,6 +30,55 @@ def get_chat_service(request: Request) -> ChatService: return request.app.state.chat_service +def get_user_store(request: Request) -> UserStore: + return request.app.state.user_store + + +@router.post( + "/auth/register", + response_model=RegisterResponse, + status_code=status.HTTP_201_CREATED, + tags=["auth"], +) +async def register( + body: RegisterRequest, + request: Request, +) -> RegisterResponse: + store = get_user_store(request) + try: + user = await store.register(body.username, body.password) + except UsernameExistsError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="username already exists", + ) from exc + except UserStoreError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return RegisterResponse(user_id=user.user_id, created_at=user.created_at) + + +@router.post( + "/auth/login", + response_model=LoginResponse, + tags=["auth"], +) +async def login( + body: LoginRequest, + request: Request, +) -> LoginResponse: + store = get_user_store(request) + try: + user = await store.login(body.username, body.password) + except InvalidCredentialsError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) from exc + except UserStoreError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return LoginResponse(user_id=user.user_id, api_key=user.api_key) + + @router.post( "/sessions", response_model=CreateSessionResponse, @@ -89,4 +148,4 @@ async def get_current_user_usage( request: Request, user: CurrentUser, ) -> UserUsageResponse: - return await get_chat_service(request).get_user_usage(user.user_id) + return await get_chat_service(request).get_user_usage(user.user_id) \ No newline at end of file diff --git a/schemas.py b/schemas.py index 26dc8ac..dee2522 100644 --- a/schemas.py +++ b/schemas.py @@ -72,3 +72,55 @@ class UserUsageResponse(BaseModel): completion_tokens: int total_tokens: int updated_at: datetime + + +class RegisterRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: str + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, value: str) -> str: + if not value.strip(): + raise ValueError("username must not be blank") + return value + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + if not value.strip(): + raise ValueError("password must not be blank") + return value + + +class RegisterResponse(BaseModel): + user_id: str + created_at: datetime + + +class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: str + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, value: str) -> str: + if not value.strip(): + raise ValueError("username must not be blank") + return value + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + if not value.strip(): + raise ValueError("password must not be blank") + return value + + +class LoginResponse(BaseModel): + user_id: str + api_key: str \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index 2dcd1b6..c4979d0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ import asyncio +from dataclasses import dataclass from datetime import datetime import json from pathlib import Path @@ -10,13 +11,20 @@ from openai import APIConnectionError import pytest from app import create_app -from config import Settings, _parse_api_key_whitelist +from config import Settings from service import ChatService from storage import JsonSessionStorage +from users import UserStore -TEST_USER_ID = "11111111-1111-4111-8111-111111111111" -OTHER_USER_ID = "22222222-2222-4222-8222-222222222222" +@dataclass +class AppHarness: + client: TestClient + provider: "FakeClient" + test_user_id: str + other_user_id: str + test_api_key: str + other_api_key: str class FakeCompletions: @@ -43,6 +51,8 @@ class FakeCompletions: total_tokens=12, ), ) + + class FakeClient: def __init__(self) -> None: self.completions = FakeCompletions() @@ -50,8 +60,14 @@ class FakeClient: @pytest.fixture -def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]: +def app_client(tmp_path: Path) -> AppHarness: provider = FakeClient() + db_path = tmp_path / "users.db" + store = UserStore(db_path) + asyncio.run(store.init()) + test_user = asyncio.run(store.register("test-user", "test-password")) + other_user = asyncio.run(store.register("other-user", "other-password")) + settings = Settings( port=8000, api_key=None, @@ -59,15 +75,19 @@ def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]: model="deepseek-v4-flash", default_system_prompt="default prompt", data_dir=tmp_path, - api_key_whitelist={ - TEST_USER_ID: "test-api-key", - OTHER_USER_ID: "other-api-key", - }, + user_db_path=db_path, ) - app = create_app(settings=settings, client=provider) # type: ignore[arg-type] + app = create_app(settings=settings, client=provider, user_store=store) # type: ignore[arg-type] with TestClient(app) as client: - client.headers["X-API-Key"] = "test-api-key" - yield client, provider + client.headers["X-API-Key"] = test_user.api_key + yield AppHarness( + client=client, + provider=provider, + test_user_id=test_user.user_id, + other_user_id=other_user.user_id, + test_api_key=test_user.api_key, + other_api_key=other_user.api_key, + ) def create_session(client: TestClient, **body: str) -> str: @@ -81,6 +101,8 @@ def test_settings_load_api_key_from_dotenv( ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + monkeypatch.delenv("USER_DB_PATH", raising=False) + monkeypatch.delenv("CHAT_DATA_DIR", raising=False) (tmp_path / ".env").write_text("DEEPSEEK_API_KEY=test-key\n") settings = Settings.from_env() @@ -88,22 +110,8 @@ def test_settings_load_api_key_from_dotenv( assert settings.api_key == "test-key" -def test_api_key_whitelist_requires_and_normalizes_uuid() -> None: - parsed = _parse_api_key_whitelist( - '{"7A007F92-86D0-4F51-931F-007B9F0B8B0E":"key"}' - ) - - assert parsed == { - "7a007f92-86d0-4f51-931f-007b9f0b8b0e": "key" - } - with pytest.raises(ValueError, match="valid UUIDs"): - _parse_api_key_whitelist('{"custom-name":"key"}') - - -def test_all_api_endpoints_require_valid_api_key( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, _ = client_and_provider +def test_all_api_endpoints_require_valid_api_key(app_client: AppHarness) -> None: + client = app_client.client del client.headers["X-API-Key"] missing = client.post("/sessions") @@ -115,10 +123,93 @@ def test_all_api_endpoints_require_valid_api_key( assert invalid.json() == {"detail": "api_key is required"} +def test_register_creates_user_and_returns_user_id(app_client: AppHarness) -> None: + client = app_client.client + + response = client.post( + "/auth/register", + json={"username": "fresh-user", "password": "fresh-password"}, + ) + + assert response.status_code == 201 + body = response.json() + assert isinstance(body["user_id"], str) + assert body["user_id"] != app_client.test_user_id + assert body["created_at"].endswith("Z") + datetime.fromisoformat(body["created_at"]) + + login = client.post( + "/auth/login", + json={"username": "fresh-user", "password": "fresh-password"}, + ) + assert login.status_code == 200 + assert login.json()["user_id"] == body["user_id"] + assert isinstance(login.json()["api_key"], str) + assert login.json()["api_key"] + + +def test_register_rejects_duplicate_username_with_409(app_client: AppHarness) -> None: + client = app_client.client + + response = client.post( + "/auth/register", + json={"username": "test-user", "password": "any-password"}, + ) + + assert response.status_code == 409 + assert response.json() == {"detail": "username already exists"} + + +def test_register_rejects_blank_fields(app_client: AppHarness) -> None: + client = app_client.client + + blank_username = client.post( + "/auth/register", json={"username": " ", "password": "secret"} + ) + blank_password = client.post( + "/auth/register", json={"username": "new-user", "password": " "} + ) + + assert blank_username.status_code == 422 + assert blank_password.status_code == 422 + + +def test_login_returns_persistent_api_key(app_client: AppHarness) -> None: + client = app_client.client + + response = client.post( + "/auth/login", + json={"username": "test-user", "password": "test-password"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["user_id"] == app_client.test_user_id + assert body["api_key"] == app_client.test_api_key + + +def test_login_invalid_credentials_returns_401(app_client: AppHarness) -> None: + client = app_client.client + + wrong_password = client.post( + "/auth/login", + json={"username": "test-user", "password": "wrong"}, + ) + unknown_user = client.post( + "/auth/login", + json={"username": "no-such-user", "password": "anything"}, + ) + + assert wrong_password.status_code == 401 + assert wrong_password.json() == {"detail": "invalid username or password"} + assert unknown_user.status_code == 401 + assert unknown_user.json() == {"detail": "invalid username or password"} + + def test_usage_endpoint_returns_current_user_totals( - client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path + app_client: AppHarness, tmp_path: Path ) -> None: - client, _ = client_and_provider + client = app_client.client session_id = create_session(client) message = client.post( f"/sessions/{session_id}/messages", json={"content": "hello"} @@ -128,7 +219,7 @@ def test_usage_endpoint_returns_current_user_totals( assert message.status_code == 200 assert response.status_code == 200 - assert response.json()["user_id"] == TEST_USER_ID + assert response.json()["user_id"] == app_client.test_user_id assert response.json()["api_calls"] == 1 assert response.json()["prompt_tokens"] == 10 assert response.json()["completion_tokens"] == 2 @@ -137,21 +228,19 @@ def test_usage_endpoint_returns_current_user_totals( def test_create_session_uses_default_prompt( - client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path + app_client: AppHarness, tmp_path: Path ) -> None: - client, _ = client_and_provider + client = app_client.client session_id = create_session(client) data = json.loads((tmp_path / f"{session_id}.json").read_text()) - assert data["user_id"] == TEST_USER_ID + assert data["user_id"] == app_client.test_user_id assert data["system_prompt"] == "default prompt" assert data["messages"] == [] -def test_create_session_without_body( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, _ = client_and_provider +def test_create_session_without_body(app_client: AppHarness) -> None: + client = app_client.client response = client.post("/sessions") @@ -159,10 +248,8 @@ def test_create_session_without_body( assert response.json()["system_prompt"] == "default prompt" -def test_create_session_accepts_custom_prompt( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, _ = client_and_provider +def test_create_session_accepts_custom_prompt(app_client: AppHarness) -> None: + client = app_client.client response = client.post("/sessions", json={"system_prompt": "回答中文"}) assert response.status_code == 201 @@ -170,9 +257,9 @@ def test_create_session_accepts_custom_prompt( def test_multi_turn_request_contains_full_history( - client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path + app_client: AppHarness, tmp_path: Path ) -> None: - client, provider = client_and_provider + client, provider = app_client.client, app_client.provider session_id = create_session(client, system_prompt="system") first = client.post( @@ -207,10 +294,8 @@ def test_multi_turn_request_contains_full_history( assert all(message["created_at"].endswith("Z") for message in data["messages"]) -def test_invalid_session_and_blank_message( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, _ = client_and_provider +def test_invalid_session_and_blank_message(app_client: AppHarness) -> None: + client = app_client.client missing = client.post( "/sessions/00000000-0000-0000-0000-000000000000/messages", @@ -226,9 +311,9 @@ def test_invalid_session_and_blank_message( def test_upstream_failure_does_not_change_history( - client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path + app_client: AppHarness, tmp_path: Path ) -> None: - client, provider = client_and_provider + client, provider = app_client.client, app_client.provider session_id = create_session(client) before = (tmp_path / f"{session_id}.json").read_text() provider.completions.error = APIConnectionError(request=object()) # type: ignore[arg-type] @@ -242,9 +327,9 @@ def test_upstream_failure_does_not_change_history( def test_corrupt_session_returns_500( - client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path + app_client: AppHarness, tmp_path: Path ) -> None: - client, _ = client_and_provider + client = app_client.client session_id = create_session(client) (tmp_path / f"{session_id}.json").write_text("not-json") @@ -256,9 +341,9 @@ def test_corrupt_session_returns_500( def test_legacy_messages_without_timestamp_remain_usable( - client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path + app_client: AppHarness, tmp_path: Path ) -> None: - client, _ = client_and_provider + client = app_client.client session_id = create_session(client) path = tmp_path / f"{session_id}.json" data = json.loads(path.read_text()) @@ -272,16 +357,14 @@ def test_legacy_messages_without_timestamp_remain_usable( assert response.status_code == 200 migrated = json.loads(path.read_text()) - assert migrated["user_id"] == TEST_USER_ID + assert migrated["user_id"] == app_client.test_user_id assert all("created_at" in message for message in migrated["messages"]) -def test_session_cannot_be_accessed_by_another_user( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, _ = client_and_provider +def test_session_cannot_be_accessed_by_another_user(app_client: AppHarness) -> None: + client = app_client.client session_id = create_session(client) - other_headers = {"X-API-Key": "other-api-key"} + other_headers = {"X-API-Key": app_client.other_api_key} history = client.get( f"/sessions/{session_id}/messages", headers=other_headers @@ -296,10 +379,8 @@ def test_session_cannot_be_accessed_by_another_user( assert message.status_code == 404 -def test_stream_parameter_is_rejected( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, provider = client_and_provider +def test_stream_parameter_is_rejected(app_client: AppHarness) -> None: + client, provider = app_client.client, app_client.provider session_id = create_session(client) response = client.post( @@ -311,17 +392,11 @@ def test_stream_parameter_is_rejected( assert provider.completions.calls == [] -def test_get_session_history_returns_all_messages( - client_and_provider: tuple[TestClient, FakeClient] -) -> None: - client, _ = client_and_provider +def test_get_session_history_returns_all_messages(app_client: AppHarness) -> None: + client = app_client.client session_id = create_session(client, system_prompt="history prompt") - client.post( - f"/sessions/{session_id}/messages", json={"content": "first"} - ) - client.post( - f"/sessions/{session_id}/messages", json={"content": "second"} - ) + client.post(f"/sessions/{session_id}/messages", json={"content": "first"}) + client.post(f"/sessions/{session_id}/messages", json={"content": "second"}) response = client.get(f"/sessions/{session_id}/messages") @@ -341,15 +416,13 @@ def test_get_session_history_returns_all_messages( def test_get_session_history_handles_empty_and_missing_sessions( - client_and_provider: tuple[TestClient, FakeClient] + app_client: AppHarness ) -> None: - client, _ = client_and_provider + client = app_client.client session_id = create_session(client) empty = client.get(f"/sessions/{session_id}/messages") - missing = client.get( - "/sessions/00000000-0000-0000-0000-000000000000/messages" - ) + missing = client.get("/sessions/00000000-0000-0000-0000-000000000000/messages") assert empty.status_code == 200 assert empty.json()["messages"] == [] @@ -380,4 +453,4 @@ def test_same_session_concurrent_messages_are_serialized(tmp_path: Path) -> None {"role": "user", "content": "second"}, ] - asyncio.run(scenario()) + asyncio.run(scenario()) \ No newline at end of file diff --git a/users.py b/users.py new file mode 100644 index 0000000..c3723c9 --- /dev/null +++ b/users.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +import hmac +import secrets +from pathlib import Path +from uuid import uuid4 + +import aiosqlite +import bcrypt + + +class UsernameExistsError(Exception): + pass + + +class InvalidCredentialsError(Exception): + pass + + +class UserStoreError(Exception): + pass + + +@dataclass(frozen=True, slots=True) +class RegisteredUser: + user_id: str + api_key: str + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class AuthenticatedUser: + user_id: str + + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + api_key TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL +); +""" + + +class UserStore: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + + async def init(self) -> None: + try: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + async with aiosqlite.connect(self.db_path) as db: + await db.execute("PRAGMA journal_mode=WAL;") + await db.execute("PRAGMA busy_timeout=5000;") + await db.execute("PRAGMA foreign_keys=ON;") + await db.executescript(_SCHEMA) + await db.commit() + except aiosqlite.Error as exc: + raise UserStoreError("could not initialize user store") from exc + + async def register(self, username: str, password: str) -> RegisteredUser: + password_hash = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + now = datetime.now(UTC) + user = RegisteredUser( + user_id=str(uuid4()), + api_key=secrets.token_urlsafe(32), + created_at=now, + ) + try: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT INTO users (user_id, username, password_hash, api_key, created_at) " + "VALUES (?, ?, ?, ?, ?)", + ( + user.user_id, + username, + password_hash, + user.api_key, + now.isoformat(), + ), + ) + await db.commit() + except aiosqlite.IntegrityError as exc: + raise UsernameExistsError(username) from exc + except aiosqlite.Error as exc: + raise UserStoreError("could not register user") from exc + return user + + async def login(self, username: str, password: str) -> RegisteredUser: + try: + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + "SELECT user_id, password_hash, api_key, created_at " + "FROM users WHERE username = ?", + (username,), + ) + row = await cursor.fetchone() + except aiosqlite.Error as exc: + raise UserStoreError("could not query user") from exc + + if row is None: + _run_constant_time_password_check() + raise InvalidCredentialsError("invalid username or password") + + user_id, password_hash, api_key, created_at = row + try: + password_ok = bcrypt.checkpw( + password.encode("utf-8"), password_hash.encode("utf-8") + ) + except ValueError as exc: + raise UserStoreError("stored password hash is corrupt") from exc + + if not password_ok: + raise InvalidCredentialsError("invalid username or password") + + try: + parsed_created_at = datetime.fromisoformat(created_at) + except ValueError as exc: + raise UserStoreError("stored created_at is corrupt") from exc + + return RegisteredUser( + user_id=user_id, + api_key=api_key, + created_at=parsed_created_at, + ) + + async def authenticate(self, api_key: str | None) -> AuthenticatedUser | None: + if not api_key: + return None + try: + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + "SELECT api_key, user_id FROM users WHERE api_key = ?", + (api_key,), + ) + row = await cursor.fetchone() + except aiosqlite.Error: + return None + + if row is None: + return None + stored_key, user_id = row + if not hmac.compare_digest(api_key, stored_key): + return None + return AuthenticatedUser(user_id=user_id) + + +def _run_constant_time_password_check() -> None: + """Perform a dummy bcrypt comparison to keep timing roughly constant. + + Equalizes the response time between an unknown username (no hash to + compare against) and a wrong password for a known username, reducing the + feasibility of username enumeration via timing. + """ + dummy_hash = bcrypt.gensalt() + bcrypt.checkpw(b"dummy-password", dummy_hash) \ No newline at end of file diff --git a/uv.lock b/uv.lock index a31efdb..a611008 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -32,6 +41,57 @@ wheels = [ { url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -363,6 +423,8 @@ name = "simple-chat-api" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aiosqlite" }, + { name = "bcrypt" }, { name = "fastapi" }, { name = "httpx", extra = ["socks"] }, { name = "openai" }, @@ -377,6 +439,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.0" }, { name = "openai", specifier = ">=1.55.0" },