Files
KF-OnlineJudge/backend/tests/test_identity.py

285 lines
13 KiB
Python

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