From a7b2324a3eaacde34a270af128a9dbeae9594f58 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Sun, 11 Oct 2026 12:31:08 +0800 Subject: [PATCH] feat(identity): expose live maintenance capabilities and transaction authorization --- backend/src/kf_oj/api/v1/identity.py | 17 +- backend/src/kf_oj/contracts/identity.py | 9 +- backend/src/kf_oj/modules/identity/README.md | 10 +- backend/src/kf_oj/modules/identity/public.py | 16 +- .../src/kf_oj/modules/identity/repository.py | 36 ++-- backend/src/kf_oj/modules/identity/service.py | 47 +++++- backend/tests/test_maintenance_authority.py | 155 ++++++++++++++++++ contracts/openapi/platform.v1.json | 27 +++ frontend/src/api/generated/schema.d.ts | 20 +++ 9 files changed, 315 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_maintenance_authority.py diff --git a/backend/src/kf_oj/api/v1/identity.py b/backend/src/kf_oj/api/v1/identity.py index 4000823..3ce4e25 100644 --- a/backend/src/kf_oj/api/v1/identity.py +++ b/backend/src/kf_oj/api/v1/identity.py @@ -9,13 +9,24 @@ from kf_oj.api.dependencies import ( trusted_origin, unsafe_principal, ) -from kf_oj.contracts.identity import CurrentUser, LoginRequest, LoginResponse +from kf_oj.contracts.identity import AccountCapabilities, 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"]) +def current_user(principal: Principal) -> CurrentUser: + return CurrentUser( + id=principal.account_id, + username=principal.username, + capabilities=AccountCapabilities( + problem_maintenance=principal.can_maintain_problems, + authorization_version=str(principal.authorization_version), + ), + ) + + @router.post("/login", response_model=LoginResponse, dependencies=[Depends(trusted_origin)]) async def login( body: LoginRequest, @@ -58,7 +69,7 @@ async def login( ) response.headers["Cache-Control"] = "no-store" return LoginResponse( - user=CurrentUser(id=issued.principal.account_id, username=issued.principal.username), + user=current_user(issued.principal), expires_at=issued.expires_at, csrf_token=issued.csrf_token, ) @@ -67,7 +78,7 @@ async def login( @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) + return current_user(principal) @router.post("/logout", status_code=204, dependencies=[Depends(unsafe_principal)]) diff --git a/backend/src/kf_oj/contracts/identity.py b/backend/src/kf_oj/contracts/identity.py index c9a23a9..bcffdce 100644 --- a/backend/src/kf_oj/contracts/identity.py +++ b/backend/src/kf_oj/contracts/identity.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated +from typing import Annotated, Literal from pydantic import Field, SecretStr @@ -11,9 +11,16 @@ class LoginRequest(WireModel): password: Annotated[SecretStr, Field(min_length=12, max_length=128)] +class AccountCapabilities(WireModel): + schema_version: Literal[1] = 1 + problem_maintenance: bool = False + authorization_version: Annotated[str, Field(pattern=r"^[1-9][0-9]{0,18}$")] = "1" + + class CurrentUser(WireModel): id: Identifier username: str + capabilities: AccountCapabilities = Field(default_factory=AccountCapabilities) class LoginResponse(WireModel): diff --git a/backend/src/kf_oj/modules/identity/README.md b/backend/src/kf_oj/modules/identity/README.md index 722ca9a..159675c 100644 --- a/backend/src/kf_oj/modules/identity/README.md +++ b/backend/src/kf_oj/modules/identity/README.md @@ -20,8 +20,14 @@ 平台账号仅可插入普通账号列、更新启用状态,不能写管理员标记或授权版本;管理员的启用状态 需要独立本地运维身份。`kf_oj_identity_operator` 与 `kf_oj_problem_import` 为无登录的独立权限组, 口令与登录身份由运维另行配置,平台不继承运维组。导入组目前没有表权限,后续导入迁移只授予 -队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员;管理员 CLI、公开能力端口 -及管理 API 尚待接通。保留授权或撤销历史后拒绝回退这次迁移。 +队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员;管理员 CLI 和管理 API +尚待接通。保留授权或撤销历史后拒绝回退这次迁移。 + +当前主体与登录响应附带版本为 1 的 `capabilities`,其中维护能力和十进制授权版本来自当前 +有效会话关联的账号;浏览器只展示这些信息,不能提交它们来获得授权。模块公开端口 +`ProblemMaintenancePort.require_problem_maintenance` 在调用方写事务中重新检查账号、授权版本 +与实时会话,并按账号→会话的顺序持有 SHARE 锁直到提交/回滚,防止验证之后撤权的竞态。 +普通构造的 `Principal` 默认没有维护能力;会话摘要仅在服务端主体内传递,不进入 DTO 或 repr。 受控启动使用 `--no-proxy-headers`,避免服务器中间件预先替换 peer;反向代理后的用户共享 该连接预算,引入可信代理的独立限流策略之前不能直接打开客户端转发头信任。 diff --git a/backend/src/kf_oj/modules/identity/public.py b/backend/src/kf_oj/modules/identity/public.py index 8e7b1ed..afd04bb 100644 --- a/backend/src/kf_oj/modules/identity/public.py +++ b/backend/src/kf_oj/modules/identity/public.py @@ -1,6 +1,7 @@ """Only verified principals cross business module boundaries.""" -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Protocol from uuid import UUID @@ -8,6 +9,19 @@ from uuid import UUID class Principal: account_id: UUID username: str + can_maintain_problems: bool = False + authorization_version: int = 1 + session_token_hash: str | None = field(default=None, repr=False, compare=False) + + +class MaintenanceDenied(ValueError): + pass + + +class ProblemMaintenancePort(Protocol): + async def require_problem_maintenance(self, session, principal: Principal) -> None: + """Recheck and lock live authority/session inside the caller's write transaction.""" + ... class AuthenticationFailed(ValueError): diff --git a/backend/src/kf_oj/modules/identity/repository.py b/backend/src/kf_oj/modules/identity/repository.py index a0470ff..6b4163a 100644 --- a/backend/src/kf_oj/modules/identity/repository.py +++ b/backend/src/kf_oj/modules/identity/repository.py @@ -51,19 +51,31 @@ async def account_by_username(session: AsyncSession, username: str) -> Account | 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), - ) +async def lock_authority(session: AsyncSession, account_id) -> Account | None: + return await session.scalar( + select(Account) + .where(Account.id == account_id) + .with_for_update(read=True) + .execution_options(populate_existing=True) + ) + + +async def current_session(session: AsyncSession, token_hash: str, *, lock_session: bool = False): + query = ( + select(Session, Account) + .join(Account) + .where( + Session.token_hash == token_hash, + Session.revoked_at.is_(None), + Session.expires_at > func.clock_timestamp(), + Account.enabled.is_(True), ) - ).one_or_none() + ) + if lock_session: + query = query.with_for_update(read=True, of=Session).execution_options( + populate_existing=True + ) + return (await session.execute(query)).one_or_none() async def prune_sessions(session: AsyncSession, account_id) -> None: diff --git a/backend/src/kf_oj/modules/identity/service.py b/backend/src/kf_oj/modules/identity/service.py index e8cb3af..73dc1c2 100644 --- a/backend/src/kf_oj/modules/identity/service.py +++ b/backend/src/kf_oj/modules/identity/service.py @@ -18,7 +18,12 @@ from kf_oj.modules.identity.passwords import ( validate_account, verify_password, ) -from kf_oj.modules.identity.public import AuthenticationFailed, LoginThrottled, Principal +from kf_oj.modules.identity.public import ( + AuthenticationFailed, + LoginThrottled, + MaintenanceDenied, + Principal, +) TOKEN = re.compile(r"[0-9a-f]{64}\Z") @@ -107,7 +112,7 @@ class IdentityService: expires_at=expires, ) ) - principal = Principal(account.id, account.username) + principal = self._principal(account, token_digest(token)) return IssuedSession(principal, expires, token, csrf) async def authenticate(self, token: str | None, *, csrf_token: str | None = None) -> Principal: @@ -123,7 +128,43 @@ class IdentityService: record.csrf_hash, token_digest(csrf_token) ): raise AuthenticationFailed("invalid request token") - return Principal(account.id, account.username) + return self._principal(account, record.token_hash) + + @staticmethod + def _principal(account: Account, session_token_hash: str) -> Principal: + return Principal( + account.id, + account.username, + account.is_administrator, + account.authorization_version, + session_token_hash, + ) + + async def require_problem_maintenance(self, session, principal: Principal) -> None: + if not session.in_transaction(): + raise ValueError("maintenance authorization requires an active write transaction") + if ( + not principal.can_maintain_problems + or principal.session_token_hash is None + or not TOKEN.fullmatch(principal.session_token_hash) + ): + raise MaintenanceDenied("problem maintenance required") + # Account first, then session: authority changes use that same lock order. + # SHARE locks last until the caller commits/rolls back; a stale principal, + # explicit logout, revocation or expired session cannot authorize a write. + account = await repository.lock_authority(session, principal.account_id) + if ( + account is None + or not account.enabled + or not account.is_administrator + or account.authorization_version != principal.authorization_version + ): + raise MaintenanceDenied("problem maintenance required") + row = await repository.current_session( + session, principal.session_token_hash, lock_session=True + ) + if row is None or row[1].id != account.id: + raise MaintenanceDenied("problem maintenance required") async def logout(self, token: str, csrf_token: str) -> None: await self.authenticate(token, csrf_token=csrf_token) diff --git a/backend/tests/test_maintenance_authority.py b/backend/tests/test_maintenance_authority.py new file mode 100644 index 0000000..949ab4e --- /dev/null +++ b/backend/tests/test_maintenance_authority.py @@ -0,0 +1,155 @@ +"""Real trusted control-plane identity checks; no grading/security VM claim.""" + +import asyncio +import secrets +from dataclasses import replace + +import httpx +import pytest +from conftest import connect +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from test_identity import restrict_platform_role + +from kf_oj.api.app import create_app +from kf_oj.contracts.identity import AccountCapabilities +from kf_oj.core.settings import Settings +from kf_oj.modules.identity.public import AuthenticationFailed, MaintenanceDenied, Principal +from kf_oj.modules.identity.service import IdentityService + + +def test_capability_wire_version_and_default_denial(): + assert AccountCapabilities().model_dump() == { + "schema_version": 1, + "problem_maintenance": False, + "authorization_version": "1", + } + for invalid in ( + {"schema_version": 2}, + {"authorization_version": "0"}, + {"authorization_version": 1}, + {"role": "admin"}, + ): + with pytest.raises(ValueError): + AccountCapabilities.model_validate(invalid) + + +async def set_administrator(connection, account_id, enabled): + async with connection.transaction(): + await connection.execute("SET LOCAL ROLE kf_oj_identity_operator") + await connection.execute( + "UPDATE accounts SET is_administrator=$1 WHERE id=$2", enabled, account_id + ) + + +def test_live_http_capabilities_and_old_cookies_after_grant_and_revoke(postgres_url): + async def run(): + app = create_app(Settings(_env_file=None, environment="test", database_url=postgres_url)) + connection = await connect(postgres_url) + try: + async with app.router.lifespan_context(app): + restrict_platform_role(app.state.sessions.kw["bind"]) + password = secrets.token_hex(16) + account = await app.state.identity.create_account("authority_user", password) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000" + ) as client: + headers = {"Origin": "http://localhost:8000"} + body = {"username": account.username, "password": password} + ordinary = await client.post("/api/v1/auth/login", json=body, headers=headers) + assert ordinary.status_code == 200 + assert ordinary.json()["user"]["capabilities"] == { + "schema_version": 1, + "problem_maintenance": False, + "authorization_version": "1", + } + await set_administrator(connection, account.account_id, True) + assert (await client.get("/api/v1/auth/me")).status_code == 401 + fresh = await client.post("/api/v1/auth/login", json=body, headers=headers) + assert fresh.status_code == 200 + user = fresh.json()["user"] + assert user["capabilities"]["problem_maintenance"] is True + assert user["capabilities"]["authorization_version"] == "2" + actual = await client.get( + "/api/v1/auth/me", headers={"X-Role": "admin", "X-User-ID": "invalid"} + ) + assert actual.json() == user + assert actual.headers["cache-control"] == "no-store" + await set_administrator(connection, account.account_id, False) + assert (await client.get("/api/v1/auth/me")).status_code == 401 + refused = await client.post( + "/api/v1/auth/login", json=body | {"role": "admin"}, headers=headers + ) + assert refused.status_code == 422 + ordinary = await client.post("/api/v1/auth/login", json=body, headers=headers) + assert ordinary.json()["user"]["capabilities"]["problem_maintenance"] is False + assert ordinary.json()["user"]["capabilities"]["authorization_version"] == "3" + finally: + await connection.close() + + asyncio.run(run()) + + +def test_write_transaction_rechecks_session_and_serializes_authority_change(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) + connection, other = await connect(postgres_url), await connect(postgres_url) + pending = None + try: + password = secrets.token_hex(16) + account = await identity.create_account("authority_user", password) + await set_administrator(connection, account.account_id, True) + issued = await identity.login(account.username, password, peer="authority-fixture") + principal = await identity.authenticate(issued.token) + assert principal.can_maintain_problems and principal.authorization_version == 2 + assert principal.session_token_hash not in repr(principal) + async with sessions.begin() as session: + await identity.require_problem_maintenance(session, principal) + pending = asyncio.create_task(set_administrator(other, account.account_id, False)) + await asyncio.sleep(0.1) + assert not pending.done() # Real PG SHARE lock holds through the write transaction. + await asyncio.wait_for(pending, timeout=5) + with pytest.raises(AuthenticationFailed): + await identity.authenticate(issued.token) + async with sessions.begin() as session: + with pytest.raises(MaintenanceDenied): + await identity.require_problem_maintenance(session, principal) + await set_administrator(connection, account.account_id, True) + issued = await identity.login(account.username, password, peer="authority-fresh") + principal = await identity.authenticate(issued.token) + async with sessions.begin() as session: + for forged in ( + Principal(account.account_id, account.username, True), + replace(principal, session_token_hash="0" * 64), + replace(principal, authorization_version=1), + ): + with pytest.raises(MaintenanceDenied): + await identity.require_problem_maintenance(session, forged) + await identity.logout(issued.token, issued.csrf_token) + async with sessions.begin() as session: + with pytest.raises(MaintenanceDenied): + await identity.require_problem_maintenance(session, principal) + issued = await identity.login(account.username, password, peer="authority-expiry") + principal = await identity.authenticate(issued.token) + # Use actual wall-clock expiry even inside an already-open transaction. + async with sessions.begin() as session: + await session.connection() + await connection.execute( + "UPDATE sessions SET expires_at=clock_timestamp()+interval '50 milliseconds' " + "WHERE token_hash=$1", + principal.session_token_hash, + ) + await asyncio.sleep(0.1) + with pytest.raises(MaintenanceDenied): + await identity.require_problem_maintenance(session, principal) + finally: + if pending is not None and not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + await connection.close() + await other.close() + await engine.dispose() + + asyncio.run(run()) diff --git a/contracts/openapi/platform.v1.json b/contracts/openapi/platform.v1.json index 24ae15f..dd879c7 100644 --- a/contracts/openapi/platform.v1.json +++ b/contracts/openapi/platform.v1.json @@ -1,6 +1,30 @@ { "components": { "schemas": { + "AccountCapabilities": { + "additionalProperties": false, + "properties": { + "authorization_version": { + "default": "1", + "pattern": "^[1-9][0-9]{0,18}$", + "title": "Authorization Version", + "type": "string" + }, + "problem_maintenance": { + "default": false, + "title": "Problem Maintenance", + "type": "boolean" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "title": "AccountCapabilities", + "type": "object" + }, "ArtifactReference": { "additionalProperties": false, "properties": { @@ -98,6 +122,9 @@ "CurrentUser": { "additionalProperties": false, "properties": { + "capabilities": { + "$ref": "#/components/schemas/AccountCapabilities" + }, "id": { "format": "uuid", "title": "Id", diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index 166f00e..fd50842 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -233,6 +233,25 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** AccountCapabilities */ + AccountCapabilities: { + /** + * Authorization Version + * @default 1 + */ + authorization_version: string; + /** + * Problem Maintenance + * @default false + */ + problem_maintenance: boolean; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; /** ArtifactReference */ ArtifactReference: { /** Bytes */ @@ -282,6 +301,7 @@ export interface components { }; /** CurrentUser */ CurrentUser: { + capabilities?: components["schemas"]["AccountCapabilities"]; /** * Id * Format: uuid