feat(identity): expose live maintenance capabilities and transaction authorization
This commit is contained in:
@@ -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)])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;反向代理后的用户共享
|
||||
该连接预算,引入可信代理的独立限流策略之前不能直接打开客户端转发头信任。
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
+20
@@ -233,6 +233,25 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user