diff --git a/backend/.env.example b/backend/.env.example index faad37a..4362e64 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 949761d..68a991a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/src/kf_oj/api/app.py b/backend/src/kf_oj/api/app.py index 0d6d81c..695658e 100644 --- a/backend/src/kf_oj/api/app.py +++ b/backend/src/kf_oj/api/app.py @@ -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 diff --git a/backend/src/kf_oj/api/body_limits.py b/backend/src/kf_oj/api/body_limits.py new file mode 100644 index 0000000..ea65bc1 --- /dev/null +++ b/backend/src/kf_oj/api/body_limits.py @@ -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) diff --git a/backend/src/kf_oj/api/dependencies.py b/backend/src/kf_oj/api/dependencies.py new file mode 100644 index 0000000..8acb089 --- /dev/null +++ b/backend/src/kf_oj/api/dependencies.py @@ -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 diff --git a/backend/src/kf_oj/api/v1/identity.py b/backend/src/kf_oj/api/v1/identity.py new file mode 100644 index 0000000..4000823 --- /dev/null +++ b/backend/src/kf_oj/api/v1/identity.py @@ -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 diff --git a/backend/src/kf_oj/contracts/identity.py b/backend/src/kf_oj/contracts/identity.py new file mode 100644 index 0000000..c9a23a9 --- /dev/null +++ b/backend/src/kf_oj/contracts/identity.py @@ -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) diff --git a/backend/src/kf_oj/core/settings.py b/backend/src/kf_oj/core/settings.py index f5fb278..2e29df1 100644 --- a/backend/src/kf_oj/core/settings.py +++ b/backend/src/kf_oj/core/settings.py @@ -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 diff --git a/backend/src/kf_oj/infra/db/session.py b/backend/src/kf_oj/infra/db/session.py index 02806dd..ade3488 100644 --- a/backend/src/kf_oj/infra/db/session.py +++ b/backend/src/kf_oj/infra/db/session.py @@ -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: diff --git a/backend/src/kf_oj/modules/identity/README.md b/backend/src/kf_oj/modules/identity/README.md index 299e021..c2b5e7e 100644 --- a/backend/src/kf_oj/modules/identity/README.md +++ b/backend/src/kf_oj/modules/identity/README.md @@ -1,5 +1,24 @@ # identity 负责账号、登录凭证、会话与角色;向其他模块提供经过验证的用户身份和授权依据。 -后续 `public.py` 暴露身份查询接口,`service.py` 编排用例,`repository.py` 封装持久化。 -用户身份不能来自前端自报字段或模型参数。目前仅建立模块边界,未实现登录。 +`public.py` 提供已验证主体,`service.py` 管理账号和可撤销会话,`repository.py` 封装持久化。 +用户身份不能来自前端自报字段或模型参数。 + +本地管理命令为 `uv run --locked kf-accounts create ` / `disable `。 +创建口令由隐藏终端提示读取和确认,不接受命令行参数、环境中的默认口令或自助注册。 +用户名为 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。 diff --git a/backend/src/kf_oj/modules/identity/cli.py b/backend/src/kf_oj/modules/identity/cli.py new file mode 100644 index 0000000..56625f4 --- /dev/null +++ b/backend/src/kf_oj/modules/identity/cli.py @@ -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() diff --git a/backend/src/kf_oj/modules/identity/passwords.py b/backend/src/kf_oj/modules/identity/passwords.py new file mode 100644 index 0000000..f46aede --- /dev/null +++ b/backend/src/kf_oj/modules/identity/passwords.py @@ -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 diff --git a/backend/src/kf_oj/modules/identity/public.py b/backend/src/kf_oj/modules/identity/public.py new file mode 100644 index 0000000..8e7b1ed --- /dev/null +++ b/backend/src/kf_oj/modules/identity/public.py @@ -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 diff --git a/backend/src/kf_oj/modules/identity/repository.py b/backend/src/kf_oj/modules/identity/repository.py new file mode 100644 index 0000000..a0470ff --- /dev/null +++ b/backend/src/kf_oj/modules/identity/repository.py @@ -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()) + ) diff --git a/backend/src/kf_oj/modules/identity/service.py b/backend/src/kf_oj/modules/identity/service.py new file mode 100644 index 0000000..e8cb3af --- /dev/null +++ b/backend/src/kf_oj/modules/identity/service.py @@ -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)) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 76a1d16..00e7424 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -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( diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py new file mode 100644 index 0000000..42de122 --- /dev/null +++ b/backend/tests/test_identity.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock index 34143de..7c004ba 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" diff --git a/contracts/openapi/platform.v1.json b/contracts/openapi/platform.v1.json index 4401d71..4040e32 100644 --- a/contracts/openapi/platform.v1.json +++ b/contracts/openapi/platform.v1.json @@ -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.", diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index 1872ce9..2d40995 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -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; 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; + /** 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; 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;