feat(identity): add bounded login and revocable sessions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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))
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Generated
+70
@@ -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"
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CurrentUser": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"username"
|
||||
],
|
||||
"title": "CurrentUser",
|
||||
"type": "object"
|
||||
},
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"title": "Detail",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object"
|
||||
},
|
||||
"HealthResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -26,16 +59,183 @@
|
||||
],
|
||||
"title": "HealthResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"LoginRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"password": {
|
||||
"format": "password",
|
||||
"maxLength": 128,
|
||||
"minLength": 12,
|
||||
"title": "Password",
|
||||
"type": "string",
|
||||
"writeOnly": true
|
||||
},
|
||||
"username": {
|
||||
"pattern": "^[a-z0-9_]{3,24}$",
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
],
|
||||
"title": "LoginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"LoginResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"maxLength": 64,
|
||||
"minLength": 64,
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"title": "Csrf Token",
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"format": "date-time",
|
||||
"title": "Expires At",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/CurrentUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"expires_at",
|
||||
"csrf_token"
|
||||
],
|
||||
"title": "LoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"ctx": {
|
||||
"title": "Context",
|
||||
"type": "object"
|
||||
},
|
||||
"input": {
|
||||
"title": "Input"
|
||||
},
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Location",
|
||||
"type": "array"
|
||||
},
|
||||
"msg": {
|
||||
"title": "Message",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"title": "Error Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"loc",
|
||||
"msg",
|
||||
"type"
|
||||
],
|
||||
"title": "ValidationError",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"description": "工程骨架。当前只提供进程存活检查,业务接口逐项实现。",
|
||||
"description": "平台 API。身份使用真实可撤销会话,业务接口按模块逐项接入。",
|
||||
"title": "KF-OnlineJudge Platform API",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"operationId": "login_api_v1_auth_login_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Login",
|
||||
"tags": [
|
||||
"identity"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"operationId": "logout_api_v1_auth_logout_post",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
}
|
||||
},
|
||||
"summary": "Logout",
|
||||
"tags": [
|
||||
"identity"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"operationId": "me_api_v1_auth_me_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CurrentUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
}
|
||||
},
|
||||
"summary": "Me",
|
||||
"tags": [
|
||||
"identity"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/health/live": {
|
||||
"get": {
|
||||
"description": "Check process liveness only; does not attest DB, queue or judge readiness.",
|
||||
|
||||
+171
@@ -4,6 +4,57 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Login */
|
||||
post: operations["login_api_v1_auth_login_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/logout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Logout */
|
||||
post: operations["logout_api_v1_auth_logout_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Me */
|
||||
get: operations["me_api_v1_auth_me_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/health/live": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -28,6 +79,21 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** CurrentUser */
|
||||
CurrentUser: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Username */
|
||||
username: string;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** HealthResponse */
|
||||
HealthResponse: {
|
||||
/**
|
||||
@@ -43,6 +109,40 @@ export interface components {
|
||||
/** Version */
|
||||
version: string;
|
||||
};
|
||||
/** LoginRequest */
|
||||
LoginRequest: {
|
||||
/**
|
||||
* Password
|
||||
* Format: password
|
||||
*/
|
||||
password: string;
|
||||
/** Username */
|
||||
username: string;
|
||||
};
|
||||
/** LoginResponse */
|
||||
LoginResponse: {
|
||||
/** Csrf Token */
|
||||
csrf_token: string;
|
||||
/**
|
||||
* Expires At
|
||||
* Format: date-time
|
||||
*/
|
||||
expires_at: string;
|
||||
user: components["schemas"]["CurrentUser"];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
/** Input */
|
||||
input?: unknown;
|
||||
/** Location */
|
||||
loc: (string | number)[];
|
||||
/** Message */
|
||||
msg: string;
|
||||
/** Error Type */
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -52,6 +152,77 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
login_api_v1_auth_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
logout_api_v1_auth_logout_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
me_api_v1_auth_me_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CurrentUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
liveness: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in New Issue
Block a user