feat(identity): add bounded login and revocable sessions

This commit is contained in:
2026-10-09 01:20:37 +08:00
parent f4bf218081
commit ff60136f8d
20 changed files with 1364 additions and 7 deletions
+2
View File
@@ -3,3 +3,5 @@ KF_OJ_LOG_LEVEL=INFO
# Optional until database-backed use cases are implemented.
KF_OJ_DATABASE_URL=postgresql+asyncpg://kf_oj:local-dev-only@127.0.0.1:5432/kf_oj
KF_OJ_AMQP_URL=amqp://kf_oj:local-dev-only@127.0.0.1:5672/kf_oj
KF_OJ_BROWSER_ORIGINS='["http://localhost:5173","http://localhost:8000"]'
KF_OJ_SESSION_LIFETIME_SECONDS=43200
+2
View File
@@ -17,9 +17,11 @@ dependencies = [
"asyncpg>=0.30,<1",
"alembic>=1.15,<2",
"aio-pika>=9.5,<10",
"argon2-cffi>=25.1,<26",
]
[project.scripts]
kf-accounts = "kf_oj.modules.identity.cli:main"
kf-learning-worker = "kf_oj.workers.learning:main"
kf-outbox-publisher = "kf_oj.workers.outbox:main"
+24 -1
View File
@@ -2,11 +2,17 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from starlette.responses import JSONResponse
from kf_oj import __version__
from kf_oj.api.body_limits import BodyLimitMiddleware
from kf_oj.api.v1.identity import router as identity_router
from kf_oj.api.v1.router import router
from kf_oj.core.logging import configure_logging
from kf_oj.core.settings import Settings
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.identity.service import IdentityService
def create_app(settings: Settings | None = None) -> FastAPI:
@@ -17,20 +23,37 @@ def create_app(settings: Settings | None = None) -> FastAPI:
configure_logging(settings.log_level)
logger = logging.getLogger("kf_oj.api")
logger.info("Platform API started")
engine = None
try:
if settings.database_url is not None:
engine = build_engine(settings)
app.state.sessions = build_session_factory(engine)
app.state.identity = IdentityService(
app.state.sessions, lifetime_seconds=settings.session_lifetime_seconds
)
yield
finally:
app.state.identity = None
if engine is not None:
await engine.dispose()
logger.info("Platform API stopped")
app = FastAPI(
title="KF-OnlineJudge Platform API",
version=__version__,
description="工程骨架。当前只提供进程存活检查,业务接口逐项实现。",
description="平台 API。身份使用真实可撤销会话,业务接口按模块逐项接入。",
docs_url="/api/docs" if settings.environment != "production" else None,
redoc_url=None,
openapi_url="/api/openapi.json" if settings.environment != "production" else None,
lifespan=lifespan,
)
app.state.settings = settings
app.add_middleware(BodyLimitMiddleware)
@app.exception_handler(RequestValidationError)
async def invalid_request(_, __):
return JSONResponse({"detail": "invalid request"}, status_code=422)
app.include_router(router, prefix="/api/v1")
app.include_router(identity_router, prefix="/api/v1")
return app
+63
View File
@@ -0,0 +1,63 @@
"""Bound request bytes and upload time before schema parsing can allocate large inputs."""
import asyncio
from starlette.responses import JSONResponse
class BodyLimitMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope["method"] in {"GET", "HEAD", "OPTIONS"}:
await self.app(scope, receive, send)
return
limit = 8192 if scope["path"] == "/api/v1/auth/login" else 1024 * 1024
headers = dict(scope.get("headers", []))
length = headers.get(b"content-length")
if length is not None:
try:
size = int(length)
if size < 0 or size > limit:
raise ValueError
except ValueError:
await JSONResponse({"detail": "request body outside byte policy"}, 413)(
scope, receive, send
)
return
# Buffer a finite body so errors can be returned before response headers are sent.
chunks, size, received_chunks = [], 0, 0
try:
async with asyncio.timeout(10):
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
body = message.get("body", b"")
size += len(body)
received_chunks += 1
if size > limit or received_chunks > 1024:
await JSONResponse({"detail": "request body outside byte policy"}, 413)(
scope, receive, send
)
return
chunks.append(body)
if not message.get("more_body", False):
break
except TimeoutError:
await JSONResponse({"detail": "request body deadline exceeded"}, 408)(
scope, receive, send
)
return
body = b"".join(chunks)
delivered = False
async def bounded_receive():
nonlocal delivered
if not delivered:
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return await receive()
await self.app(scope, bounded_receive, send)
+50
View File
@@ -0,0 +1,50 @@
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from kf_oj.core.settings import Settings
from kf_oj.modules.identity.public import AuthenticationFailed, Principal
from kf_oj.modules.identity.service import IdentityService
SESSION_COOKIE = "kf_session"
def identity_service(request: Request) -> IdentityService:
service = getattr(request.app.state, "identity", None)
if service is None:
raise HTTPException(503, "identity service unavailable")
return service
def trusted_origin(request: Request) -> None:
settings: Settings = request.app.state.settings
if request.headers.get("origin") not in settings.browser_origins:
raise HTTPException(403, "request origin rejected")
if request.headers.get("sec-fetch-site") == "cross-site":
raise HTTPException(403, "cross-site request rejected")
async def current_principal(
request: Request, identity: Annotated[IdentityService, Depends(identity_service)]
) -> Principal:
try:
return await identity.authenticate(request.cookies.get(SESSION_COOKIE))
except AuthenticationFailed:
raise HTTPException(401, "authentication required") from None
async def unsafe_principal(
request: Request, identity: Annotated[IdentityService, Depends(identity_service)]
) -> Principal:
trusted_origin(request)
try:
await identity.authenticate(request.cookies.get(SESSION_COOKIE))
except AuthenticationFailed:
raise HTTPException(401, "authentication required") from None
csrf = request.headers.get("x-csrf-token")
if csrf is None:
raise HTTPException(403, "request token required")
try:
return await identity.authenticate(request.cookies.get(SESSION_COOKIE), csrf_token=csrf)
except AuthenticationFailed:
raise HTTPException(403, "request authentication rejected") from None
+95
View File
@@ -0,0 +1,95 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from kf_oj.api.dependencies import (
SESSION_COOKIE,
current_principal,
identity_service,
trusted_origin,
unsafe_principal,
)
from kf_oj.contracts.identity import CurrentUser, LoginRequest, LoginResponse
from kf_oj.modules.identity.public import AuthenticationFailed, LoginThrottled, Principal
from kf_oj.modules.identity.service import IdentityService
router = APIRouter(prefix="/auth", tags=["identity"])
@router.post("/login", response_model=LoginResponse, dependencies=[Depends(trusted_origin)])
async def login(
body: LoginRequest,
request: Request,
response: Response,
identity: Annotated[IdentityService, Depends(identity_service)],
) -> LoginResponse:
if request.headers.get("content-type", "").split(";", 1)[0] != "application/json":
raise HTTPException(415, "JSON request required")
try:
issued = await identity.login(
body.username,
body.password.get_secret_value(),
peer=request.client.host if request.client else "",
)
except LoginThrottled:
raise HTTPException(
429, "login temporarily limited", headers={"Retry-After": "900"}
) from None
except (AuthenticationFailed, ValueError):
raise HTTPException(401, "invalid credentials") from None
settings = request.app.state.settings
response.set_cookie(
SESSION_COOKIE,
issued.token,
max_age=settings.session_lifetime_seconds,
httponly=True,
secure=settings.environment == "production",
samesite="strict",
path="/api/v1",
)
response.set_cookie(
"kf_csrf",
issued.csrf_token,
max_age=settings.session_lifetime_seconds,
httponly=False,
secure=settings.environment == "production",
samesite="strict",
path="/",
)
response.headers["Cache-Control"] = "no-store"
return LoginResponse(
user=CurrentUser(id=issued.principal.account_id, username=issued.principal.username),
expires_at=issued.expires_at,
csrf_token=issued.csrf_token,
)
@router.get("/me", response_model=CurrentUser)
async def me(response: Response, principal: Annotated[Principal, Depends(current_principal)]):
response.headers["Cache-Control"] = "no-store"
return CurrentUser(id=principal.account_id, username=principal.username)
@router.post("/logout", status_code=204, dependencies=[Depends(unsafe_principal)])
async def logout(
request: Request, identity: Annotated[IdentityService, Depends(identity_service)]
) -> Response:
try:
await identity.logout(request.cookies[SESSION_COOKIE], request.headers["x-csrf-token"])
except AuthenticationFailed:
raise HTTPException(401, "authentication required") from None
response = Response(status_code=204, headers={"Cache-Control": "no-store"})
response.delete_cookie(
SESSION_COOKIE,
path="/api/v1",
httponly=True,
secure=request.app.state.settings.environment == "production",
samesite="strict",
)
response.delete_cookie(
"kf_csrf",
path="/",
secure=request.app.state.settings.environment == "production",
samesite="strict",
)
return response
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime
from typing import Annotated
from pydantic import Field, SecretStr
from kf_oj.contracts.judging import Digest, Identifier, WireModel
class LoginRequest(WireModel):
username: Annotated[str, Field(strict=True, pattern=r"^[a-z0-9_]{3,24}$")]
password: Annotated[SecretStr, Field(min_length=12, max_length=128)]
class CurrentUser(WireModel):
id: Identifier
username: str
class LoginResponse(WireModel):
user: CurrentUser
expires_at: datetime
csrf_token: Digest = Field(repr=False)
+26 -1
View File
@@ -1,6 +1,6 @@
from typing import Literal
from pydantic import SecretStr
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -11,3 +11,28 @@ class Settings(BaseSettings):
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
database_url: SecretStr | None = None
amqp_url: SecretStr | None = None
browser_origins: tuple[str, ...] = ("http://localhost:5173", "http://localhost:8000")
session_lifetime_seconds: int = Field(default=43200, ge=300, le=86400)
@model_validator(mode="after")
def secure_origins(self):
from urllib.parse import urlsplit
if not self.browser_origins or len(self.browser_origins) > 8:
raise ValueError("one to eight browser origins required")
for origin in self.browser_origins:
parts = urlsplit(origin)
if (
parts.scheme not in {"http", "https"}
or not parts.hostname
or parts.username
or parts.password
or parts.path
or parts.query
or parts.fragment
or (self.environment == "production" and parts.scheme != "https")
):
raise ValueError(
"browser origins must be explicit origins; production requires HTTPS"
)
return self
+15 -1
View File
@@ -6,7 +6,21 @@ from kf_oj.core.settings import Settings
def build_engine(settings: Settings) -> AsyncEngine:
if settings.database_url is None:
raise ValueError("KF_OJ_DATABASE_URL is required for database-backed operations")
return create_async_engine(settings.database_url.get_secret_value(), pool_pre_ping=True)
return create_async_engine(
settings.database_url.get_secret_value(),
pool_pre_ping=True,
pool_size=5,
max_overflow=0,
pool_timeout=5,
connect_args={
"command_timeout": 10,
"server_settings": {
"statement_timeout": "10000",
"lock_timeout": "5000",
"idle_in_transaction_session_timeout": "30000",
},
},
)
def build_session_factory(engine: AsyncEngine) -> async_sessionmaker:
+21 -2
View File
@@ -1,5 +1,24 @@
# identity
负责账号、登录凭证、会话与角色;向其他模块提供经过验证的用户身份和授权依据。
后续 `public.py` 暴露身份查询接口,`service.py` 编排用例,`repository.py` 封装持久化。
用户身份不能来自前端自报字段或模型参数。目前仅建立模块边界,未实现登录。
`public.py` 提供已验证主体,`service.py` 管理账号和可撤销会话,`repository.py` 封装持久化。
用户身份不能来自前端自报字段或模型参数。
本地管理命令为 `uv run --locked kf-accounts create <username>` / `disable <username>`。
创建口令由隐藏终端提示读取和确认,不接受命令行参数、环境中的默认口令或自助注册。
用户名为 3–24 个小写 ASCII 字母、数字或下划线;口令 12–128 字符,最多 512 UTF-8 字节。
密码摘要由新增的 `argon2-cffi` 依赖提供 Argon2id,固定 19 MiB/2 次/1 并行度。
参数依据 [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
和 [argon2-cffi 文档](https://argon2-cffi.readthedocs.io/en/stable/parameters.html)。
登录接口为 `/api/v1/auth/login`,退出 `/logout`,当前主体 `/me`。会话使用随机 256-bit token,
数据库只保存 token / CSRF 摘要;默认 12 小时有效,单账号保留至多 10 个有效会话。
账号禁用立即拒绝登录和旧会话。请求只采用实际连接 peer,不信任客户端自报身份或转发地址。
每 peer 至多 10 次登录/15 分钟,预算通过真实数据库串行更新,失败尝试也消耗预算。
摘要计算最多同时 2 次,超出直接 429;限流表至多 4096 桶且每次有界回收过期桶。
有副作用的浏览器请求必须匹配 `KF_OJ_BROWSER_ORIGINS` 的显式来源;生产只接受 HTTPS 来源,
必须提供生产来源配置。会话 Cookie 为 HttpOnly / SameSite=Strict,生产 Secure;CSRF Cookie
供前端读出请求 token,在 `X-CSRF-Token` 携带,服务端核对其与当前会话绑定的摘要。
登录、主体和退出响应不缓存;请求校验错误不回显密码或输入值。API 层限制请求字节、块数和
读取期限。跨服务 Worker 凭证不使用用户 Cookie。
+49
View File
@@ -0,0 +1,49 @@
"""Controlled local account management; passwords are read from the terminal only."""
import argparse
import asyncio
import getpass
from sqlalchemy.exc import IntegrityError
from kf_oj.core.settings import Settings
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.identity.service import IdentityService
async def run(command: str, username: str, password: str | None = None) -> None:
engine = build_engine(Settings())
try:
service = IdentityService(build_session_factory(engine))
if command == "create":
assert password is not None
await service.create_account(username, password)
print("Account created.")
else:
if not await service.disable_account(username):
raise ValueError("account not found")
print("Account disabled; sessions revoked.")
finally:
await engine.dispose()
def main() -> None:
parser = argparse.ArgumentParser(description="Manage local OJ accounts")
parser.add_argument("command", choices=("create", "disable"))
parser.add_argument("username")
args = parser.parse_args()
password = None
if args.command == "create":
password = getpass.getpass("Password (12–128 characters): ")
if password != getpass.getpass("Repeat password: "):
parser.error("passwords differ")
try:
asyncio.run(run(args.command, args.username, password))
except IntegrityError:
parser.exit(1, "Account operation rejected by database constraints.\n")
except ValueError as error:
parser.exit(1, f"Account operation rejected: {error}.\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
"""Argon2id password verification with explicit bounded parameters."""
import re
from argon2 import PasswordHasher, Type
from argon2.exceptions import InvalidHashError, VerificationError
USERNAME = re.compile(r"[a-z0-9_]{3,24}\Z")
HASHER = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1, type=Type.ID)
def validate_account(username: str, password: str) -> None:
if not USERNAME.fullmatch(username) or not 12 <= len(password) <= 128:
raise ValueError("username or password outside account policy")
if len(password.encode("utf-8")) > 512:
raise ValueError("password outside byte policy")
def hash_password(username: str, password: str) -> str:
validate_account(username, password)
return HASHER.hash(password)
def verify_password(stored: str, password: str) -> bool:
try:
# Stored hashes are owner-controlled, but don't allow corrupted parameters to allocate
# arbitrary memory. Account creation always uses these exact parameters.
if not stored.startswith("$argon2id$v=19$m=19456,t=2,p=1$"):
return False
return HASHER.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
@@ -0,0 +1,18 @@
"""Only verified principals cross business module boundaries."""
from dataclasses import dataclass
from uuid import UUID
@dataclass(frozen=True)
class Principal:
account_id: UUID
username: str
class AuthenticationFailed(ValueError):
pass
class LoginThrottled(ValueError):
pass
@@ -0,0 +1,85 @@
from datetime import datetime
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from kf_oj.modules.identity.models import Account, LoginThrottle, Session
from kf_oj.modules.identity.public import LoginThrottled
async def consume_login_budget(
session: AsyncSession, bucket: str, now: datetime, reset_at: datetime
) -> None:
# An upsert establishes the key before FOR UPDATE; requests share one DB-backed budget.
inserted = await session.scalar(
insert(LoginThrottle)
.values(bucket_hash=bucket, attempts=1, reset_at=reset_at)
.on_conflict_do_nothing(index_elements=[LoginThrottle.bucket_hash])
.returning(LoginThrottle.bucket_hash)
)
row = await session.scalar(
select(LoginThrottle).where(LoginThrottle.bucket_hash == bucket).with_for_update()
)
assert row is not None
# The initial upsert consumes this request only if there was no prior row.
# A separate returning flag avoids granting an extra request during concurrency.
if inserted is not None:
return
if row.reset_at <= now:
row.attempts, row.reset_at = 1, reset_at
elif row.attempts >= 10:
raise LoginThrottled("login request budget exhausted")
else:
row.attempts += 1
async def prune_throttles(session: AsyncSession, now: datetime, *, limit: int = 100) -> None:
await session.execute(text("SELECT pg_advisory_xact_lock(-740164777)"))
keys = select(LoginThrottle.bucket_hash).where(LoginThrottle.reset_at <= now)
keys = keys.order_by(LoginThrottle.reset_at).limit(limit).with_for_update(skip_locked=True)
await session.execute(delete(LoginThrottle).where(LoginThrottle.bucket_hash.in_(keys)))
async def check_throttle_capacity(session: AsyncSession, bucket: str) -> None:
if await session.get(LoginThrottle, bucket) is None:
if await session.scalar(select(func.count()).select_from(LoginThrottle)) >= 4096:
raise LoginThrottled("login throttle capacity exhausted")
async def account_by_username(session: AsyncSession, username: str) -> Account | None:
return await session.scalar(select(Account).where(Account.username == username))
async def current_session(session: AsyncSession, token_hash: str):
return (
await session.execute(
select(Session, Account)
.join(Account)
.where(
Session.token_hash == token_hash,
Session.revoked_at.is_(None),
Session.expires_at > func.now(),
Account.enabled.is_(True),
)
)
).one_or_none()
async def prune_sessions(session: AsyncSession, account_id) -> None:
await session.execute(
delete(Session).where(
Session.account_id == account_id,
(Session.expires_at <= func.now()) | Session.revoked_at.is_not(None),
)
)
# Keep at most nine older valid sessions before adding the new one.
old = select(Session.token_hash).where(Session.account_id == account_id)
old = old.order_by(Session.created_at.desc(), Session.token_hash).offset(9)
await session.execute(delete(Session).where(Session.token_hash.in_(old)))
async def revoke(session: AsyncSession, token_hash: str) -> None:
await session.execute(
update(Session).where(Session.token_hash == token_hash).values(revoked_at=func.now())
)
@@ -0,0 +1,131 @@
import asyncio
import hashlib
import hmac
import re
import secrets
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from uuid import uuid4
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.modules.identity import repository
from kf_oj.modules.identity.models import Account, Session
from kf_oj.modules.identity.passwords import (
HASHER,
hash_password,
validate_account,
verify_password,
)
from kf_oj.modules.identity.public import AuthenticationFailed, LoginThrottled, Principal
TOKEN = re.compile(r"[0-9a-f]{64}\Z")
def token_digest(value: str) -> str:
return hashlib.sha256(value.encode("ascii")).hexdigest()
@dataclass(frozen=True)
class IssuedSession:
principal: Principal
expires_at: datetime
token: str = field(repr=False)
csrf_token: str = field(repr=False)
class IdentityService:
def __init__(self, sessions: async_sessionmaker, *, lifetime_seconds: int = 43200):
if not 300 <= lifetime_seconds <= 86400:
raise ValueError("invalid session lifetime")
self.sessions = sessions
self.lifetime_seconds = lifetime_seconds
self._hash_slots = asyncio.Semaphore(2)
self._dummy_hash = HASHER.hash(secrets.token_hex(32))
async def create_account(self, username: str, password: str) -> Principal:
hashed = await asyncio.to_thread(hash_password, username, password)
async with self.sessions.begin() as session:
account = Account(id=uuid4(), username=username, password_hash=hashed)
session.add(account)
return Principal(account.id, username)
async def disable_account(self, username: str) -> bool:
async with self.sessions.begin() as session:
account = await repository.account_by_username(session, username)
if account is None:
return False
account.enabled = False
await session.execute(
update(Session)
.where(Session.account_id == account.id)
.values(revoked_at=func.now())
)
return True
async def login(self, username: str, password: str, *, peer: str) -> IssuedSession:
validate_account(username, password)
if not peer or len(peer) > 128:
raise AuthenticationFailed("invalid login context")
# Fail fast instead of allowing an unbounded password-hash queue.
if self._hash_slots.locked():
raise LoginThrottled("password verification capacity exhausted")
async with self._hash_slots:
async with self.sessions.begin() as session:
now = await session.scalar(select(func.now()))
assert now is not None
await repository.prune_throttles(session, now)
# Direct peer only; caller must never substitute forwarded client headers.
bucket = hashlib.sha256(peer.encode("utf-8")).hexdigest()
await repository.check_throttle_capacity(session, bucket)
await repository.consume_login_budget(
session, bucket, now, now + timedelta(minutes=15)
)
async with self.sessions() as session:
account = await repository.account_by_username(session, username)
hashed = account.password_hash if account is not None else self._dummy_hash
correct = await asyncio.to_thread(verify_password, hashed, password)
if not correct or account is None or not account.enabled:
raise AuthenticationFailed("invalid credentials")
token, csrf = secrets.token_hex(32), secrets.token_hex(32)
async with self.sessions.begin() as session:
# Serialize with disabling the account and concurrent logins before issuing.
account = await session.scalar(
select(Account).where(Account.id == account.id).with_for_update()
)
if account is None or not account.enabled:
raise AuthenticationFailed("invalid credentials")
now = await session.scalar(select(func.now()))
expires = now + timedelta(seconds=self.lifetime_seconds)
await repository.prune_sessions(session, account.id)
session.add(
Session(
token_hash=token_digest(token),
account_id=account.id,
csrf_hash=token_digest(csrf),
expires_at=expires,
)
)
principal = Principal(account.id, account.username)
return IssuedSession(principal, expires, token, csrf)
async def authenticate(self, token: str | None, *, csrf_token: str | None = None) -> Principal:
if token is None or not TOKEN.fullmatch(token):
raise AuthenticationFailed("invalid session")
async with self.sessions() as session:
row = await repository.current_session(session, token_digest(token))
if row is None:
raise AuthenticationFailed("invalid session")
record, account = row
if csrf_token is not None:
if not TOKEN.fullmatch(csrf_token) or not hmac.compare_digest(
record.csrf_hash, token_digest(csrf_token)
):
raise AuthenticationFailed("invalid request token")
return Principal(account.id, account.username)
async def logout(self, token: str, csrf_token: str) -> None:
await self.authenticate(token, csrf_token=csrf_token)
async with self.sessions.begin() as session:
await repository.revoke(session, token_digest(token))
+3 -1
View File
@@ -21,7 +21,9 @@ def test_liveness_without_database_or_queue() -> None:
def test_production_disables_api_documentation() -> None:
app = create_app(Settings(_env_file=None, environment="production"))
app = create_app(
Settings(_env_file=None, environment="production", browser_origins=("https://oj.example",))
)
async def request():
async with httpx.AsyncClient(
+284
View File
@@ -0,0 +1,284 @@
import asyncio
import os
import pty
import secrets
import select as io_select
import signal
import sys
import time
from datetime import timedelta
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import event, func, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from kf_oj.api.app import create_app
from kf_oj.contracts.identity import LoginRequest
from kf_oj.core.settings import Settings
from kf_oj.modules.identity.models import Account, Session
from kf_oj.modules.identity.passwords import hash_password, verify_password
from kf_oj.modules.identity.public import AuthenticationFailed, LoginThrottled
from kf_oj.modules.identity.service import IdentityService, token_digest
def restrict_platform_role(engine):
@event.listens_for(engine.sync_engine, "connect")
def apply_role(connection, _):
cursor = connection.cursor()
cursor.execute("SET ROLE kf_oj_platform")
cursor.close()
def test_password_policy_and_credentials_redacted():
password = secrets.token_hex(16)
hashed = hash_password("fixture_user", password)
assert hashed.startswith("$argon2id$v=19$m=19456,t=2,p=1$")
assert verify_password(hashed, password)
assert not verify_password(hashed, secrets.token_hex(16))
assert not verify_password("$argon2id$v=19$m=999999999,t=2,p=1$invalid", password)
assert password not in repr(LoginRequest(username="fixture_user", password=password))
with pytest.raises(ValueError):
hash_password("not canonical", password)
with pytest.raises(ValueError):
hash_password("fixture_user", "too short")
def test_production_cookie_configuration_requires_explicit_https_origins():
with pytest.raises(ValueError):
Settings(_env_file=None, environment="production")
for invalid in ("https://oj.example/path", "https://user@oj.example", "*", "null"):
with pytest.raises(ValueError):
Settings(_env_file=None, browser_origins=(invalid,))
def test_real_login_session_revocation_expiry_and_throttle(postgres_url):
async def run():
engine = create_async_engine(postgres_url)
restrict_platform_role(engine)
sessions = async_sessionmaker(engine, expire_on_commit=False)
identity = IdentityService(sessions, lifetime_seconds=300)
password = secrets.token_hex(16)
try:
principal = await identity.create_account("fixture_user", password)
with pytest.raises(AuthenticationFailed):
await identity.login("fixture_user", secrets.token_hex(16), peer="fixture-peer")
with pytest.raises(AuthenticationFailed):
await identity.login("absent_user", password, peer="fixture-peer")
issued = await identity.login("fixture_user", password, peer="fixture-peer")
assert await identity.authenticate(issued.token) == principal
assert issued.token not in repr(issued) and issued.csrf_token not in repr(issued)
with pytest.raises(AuthenticationFailed):
await identity.authenticate(str(principal.account_id))
with pytest.raises(AuthenticationFailed):
await identity.authenticate(issued.token, csrf_token="f" * 64)
await identity.logout(issued.token, issued.csrf_token)
with pytest.raises(AuthenticationFailed):
await identity.authenticate(issued.token)
issued = await identity.login("fixture_user", password, peer="expiry-peer")
async with sessions.begin() as session:
# Keep the expiry constraint valid while moving the entire session into the past.
await session.execute(
update(Session).values(
created_at=func.now() - timedelta(minutes=10),
expires_at=func.now() - timedelta(minutes=5),
)
)
with pytest.raises(AuthenticationFailed):
await identity.authenticate(issued.token)
issued = await identity.login("fixture_user", password, peer="disable-peer")
assert await identity.disable_account("fixture_user")
with pytest.raises(AuthenticationFailed):
await identity.authenticate(issued.token)
with pytest.raises(AuthenticationFailed):
await identity.login("fixture_user", password, peer="disable-peer")
assert not await identity.disable_account("unknown_user")
# Exactly ten real failed attempts consume the same persistent peer budget.
for _ in range(10):
with pytest.raises(AuthenticationFailed):
await identity.login("absent_user", password, peer="limited-peer")
with pytest.raises(LoginThrottled):
await identity.login("absent_user", password, peer="limited-peer")
async with sessions() as session:
account = await session.scalar(select(Account))
assert password != account.password_hash and password not in account.password_hash
rows = list(await session.scalars(select(Session)))
assert all(row.token_hash != issued.token for row in rows)
finally:
await engine.dispose()
asyncio.run(run())
def test_real_http_cookie_csrf_and_identity_spoofing_rejection(postgres_url, caplog):
async def run():
app = create_app(
Settings(
_env_file=None,
environment="production",
database_url=postgres_url,
browser_origins=("https://test",),
)
)
async with app.router.lifespan_context(app):
restrict_platform_role(app.state.sessions.kw["bind"])
password = secrets.token_hex(16)
first = await app.state.identity.create_account("first_user", password)
second = await app.state.identity.create_account("second_user", secrets.token_hex(16))
headers = {"Origin": "https://test"}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://test"
) as client:
body = {"username": "first_user", "password": password}
assert (await client.get("/api/v1/auth/me")).status_code == 401
assert (await client.post("/api/v1/auth/login", json=body)).status_code == 403
assert (
await client.post(
"/api/v1/auth/login",
json=body,
headers={"Origin": "https://hostile.example"},
)
).status_code == 403
invalid = await client.post(
"/api/v1/auth/login",
headers=headers,
json=body | {"user_id": str(second.account_id), "role": "admin"},
)
assert invalid.status_code == 422
assert password not in invalid.text and "admin" not in invalid.text
response = await client.post("/api/v1/auth/login", json=body, headers=headers)
assert response.status_code == 200
assert response.json()["user"]["id"] == str(first.account_id)
assert (
await client.get(
"/api/v1/auth/me", headers={"X-User-ID": str(second.account_id)}
)
).json()["id"] == str(first.account_id)
cookie = response.headers.get_list("set-cookie")[0]
assert "HttpOnly" in cookie and "Secure" in cookie and "SameSite=strict" in cookie
assert response.headers["cache-control"] == "no-store"
assert client.cookies.get("kf_csrf") == response.json()["csrf_token"]
assert (
await client.post("/api/v1/auth/logout", headers=headers)
).status_code == 403
assert (
await client.post(
"/api/v1/auth/logout", headers=headers | {"X-CSRF-Token": "f" * 64}
)
).status_code == 403
assert (
await client.post(
"/api/v1/auth/logout",
headers=headers | {"X-CSRF-Token": response.json()["csrf_token"]},
)
).status_code == 204
assert (await client.get("/api/v1/auth/me")).status_code == 401
assert not client.cookies.get("kf_session") and not client.cookies.get("kf_csrf")
assert (
password not in caplog.text and response.json()["csrf_token"] not in caplog.text
)
asyncio.run(run())
def test_real_concurrent_logins_keep_bounded_active_sessions(postgres_url):
async def run():
engine = create_async_engine(postgres_url)
restrict_platform_role(engine)
sessions = async_sessionmaker(engine, expire_on_commit=False)
identity = IdentityService(sessions)
password = secrets.token_hex(16)
try:
await identity.create_account("fixture_user", password)
# Independent peer budgets exercise session bounds without exhausting login retries.
issued = []
for _ in range(12):
issued.append(await identity.login("fixture_user", password, peer=uuid4().hex))
with pytest.raises(AuthenticationFailed):
await identity.authenticate(issued[0].token)
results = await asyncio.gather(
*(identity.login("fixture_user", password, peer=uuid4().hex) for _ in range(3)),
return_exceptions=True,
)
assert sum(isinstance(result, LoginThrottled) for result in results) == 1
assert sum(not isinstance(result, Exception) for result in results) == 2
async with sessions() as session:
assert await session.scalar(select(func.count()).select_from(Session)) == 10
assert (
await session.scalar(
select(func.count())
.select_from(Session)
.where(Session.token_hash == token_digest("0" * 64))
)
== 0
)
finally:
await engine.dispose()
asyncio.run(run())
def test_account_cli_hidden_password_and_disable(postgres_url):
password = secrets.token_hex(16)
# The CLI prompts through a real private PTY; the password is never an argument/environment.
pid, descriptor = pty.fork()
if pid == 0:
os.environ["KF_OJ_DATABASE_URL"] = postgres_url
os.execv(
sys.executable,
[sys.executable, "-m", "kf_oj.modules.identity.cli", "create", "cli_user"],
)
output = b""
deadline, prompts, status = time.monotonic() + 15, 0, None
try:
while time.monotonic() < deadline:
if io_select.select([descriptor], [], [], 0.1)[0]:
try:
block = os.read(descriptor, 4096)
except OSError:
block = b""
output += block
if prompts == 0 and b"Password (12" in output:
os.write(descriptor, password.encode() + b"\n")
prompts = 1
if prompts == 1 and b"Repeat password:" in output:
os.write(descriptor, password.encode() + b"\n")
prompts = 2
result, status = os.waitpid(pid, os.WNOHANG)
if result:
break
else:
os.kill(pid, signal.SIGTERM)
os.waitpid(pid, 0)
pytest.fail("account CLI deadline exceeded")
assert os.waitstatus_to_exitcode(status) == 0
assert prompts == 2 and b"Account created." in output
assert password.encode() not in output
finally:
os.close(descriptor)
async def inspect():
engine = create_async_engine(postgres_url)
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
service = IdentityService(sessions)
issued = await service.login("cli_user", password, peer="cli-fixture")
from kf_oj.modules.identity.cli import run
await run("disable", "cli_user")
with pytest.raises(AuthenticationFailed):
await service.authenticate(issued.token)
finally:
await engine.dispose()
# CLI run uses the test DB only for this call; don't reuse a configured business database.
original = os.environ.get("KF_OJ_DATABASE_URL")
os.environ["KF_OJ_DATABASE_URL"] = postgres_url
try:
asyncio.run(inspect())
finally:
if original is None:
os.environ.pop("KF_OJ_DATABASE_URL", None)
else:
os.environ["KF_OJ_DATABASE_URL"] = original
+70
View File
@@ -73,6 +73,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
@@ -107,6 +141,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -249,6 +308,7 @@ source = { editable = "." }
dependencies = [
{ name = "aio-pika" },
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "pydantic" },
@@ -269,6 +329,7 @@ dev = [
requires-dist = [
{ name = "aio-pika", specifier = ">=9.5,<10" },
{ name = "alembic", specifier = ">=1.15,<2" },
{ name = "argon2-cffi", specifier = ">=25.1,<26" },
{ name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "pydantic", specifier = ">=2.10,<3" },
@@ -422,6 +483,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
]
[[package]]
name = "pycparser"
version = "3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"