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_session_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") assert await service.disable_account("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