feat(access): add guarded local password lifecycle and recovery
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
import test_password_recovery as recovery_fixture
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.db.models import PasswordRecovery
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get("GOVOPLAN_ACCESS_TEST_POSTGRES_URL"),
|
||||
"set GOVOPLAN_ACCESS_TEST_POSTGRES_URL to a disposable PostgreSQL test database",
|
||||
)
|
||||
class PasswordRecoveryPostgresTests(unittest.TestCase):
|
||||
"""Opt-in real transaction races; no application/default database fallback."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
url = make_url(os.environ["GOVOPLAN_ACCESS_TEST_POSTGRES_URL"])
|
||||
if url.get_backend_name() != "postgresql" or not url.database:
|
||||
raise ValueError("An explicit disposable PostgreSQL database is required")
|
||||
self.schema = "access_password_race_" + uuid.uuid4().hex
|
||||
self.admin_engine = create_engine(url)
|
||||
self.addCleanup(self.admin_engine.dispose)
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(text(f'CREATE SCHEMA "{self.schema}"'))
|
||||
self.addCleanup(self._drop_schema)
|
||||
self.engine = create_engine(
|
||||
url,
|
||||
connect_args={
|
||||
"options": (
|
||||
f"-c search_path={self.schema} -c statement_timeout=30000 "
|
||||
"-c lock_timeout=15000 -c idle_in_transaction_session_timeout=60000"
|
||||
)
|
||||
},
|
||||
)
|
||||
self.addCleanup(self.engine.dispose)
|
||||
self.access = recovery_fixture.PasswordRecoveryTests(methodName="runTest")
|
||||
self.addCleanup(self._close_fixture)
|
||||
# Reuse the synthetic HTTP/security fixture, replacing only its SQLite
|
||||
# engine. The production account locks/CAS and request SQL sessions run.
|
||||
with patch.object(recovery_fixture, "create_engine", return_value=self.engine):
|
||||
self.access.setUp()
|
||||
|
||||
def _drop_schema(self) -> None:
|
||||
# The identifier is generated here, never read from the supplied URL.
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(text(f'DROP SCHEMA "{self.schema}" CASCADE'))
|
||||
|
||||
def _close_fixture(self) -> None:
|
||||
try:
|
||||
if hasattr(self.access, "client"):
|
||||
self.access.client.close()
|
||||
if hasattr(self.access, "db"):
|
||||
self.access.db.close()
|
||||
finally:
|
||||
self.access.doCleanups()
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def _post(self, endpoint, payload, headers=None):
|
||||
# Separate cookie jars and real per-request SQL sessions in each thread.
|
||||
with TestClient(self.access.client.app) as client:
|
||||
return client.post(
|
||||
"/api/v1/auth/password/" + endpoint,
|
||||
json=payload,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
def _race(self, endpoint, payloads, *, headers=None, locked_account="person"):
|
||||
barrier = threading.Barrier(2)
|
||||
real_lock = auth.locked_local_account
|
||||
|
||||
def synchronized_lock(session, account_id):
|
||||
if account_id == locked_account:
|
||||
barrier.wait(timeout=10)
|
||||
return real_lock(session, account_id)
|
||||
|
||||
with patch.object(auth, "locked_local_account", side_effect=synchronized_lock):
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(self._post, endpoint, payload, headers)
|
||||
for payload in payloads
|
||||
]
|
||||
return [future.result(timeout=35) for future in futures]
|
||||
|
||||
def test_same_recovery_code_has_exactly_one_winner(self):
|
||||
issued = self.access.issue()
|
||||
self.assertEqual(200, issued.status_code)
|
||||
code = issued.json()["recovery_code"]
|
||||
passwords = ["Concurrent-recovery-one", "Concurrent-recovery-two"]
|
||||
responses = self._race(
|
||||
"recover",
|
||||
[
|
||||
{
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": value,
|
||||
}
|
||||
for value in passwords
|
||||
],
|
||||
)
|
||||
self.assertEqual([200, 400], sorted(item.status_code for item in responses))
|
||||
winner = next(
|
||||
index for index, item in enumerate(responses) if item.status_code == 200
|
||||
)
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password(passwords[winner], self.access.account.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||
|
||||
def test_same_old_password_has_exactly_one_winner(self):
|
||||
passwords = ["Concurrent-change-one", "Concurrent-change-two"]
|
||||
responses = self._race(
|
||||
"change",
|
||||
[
|
||||
{"current_password": "Initial-password", "new_password": value}
|
||||
for value in passwords
|
||||
],
|
||||
headers=self.access.headers(),
|
||||
)
|
||||
self.assertEqual([200, 401], sorted(item.status_code for item in responses))
|
||||
winner = next(
|
||||
index for index, item in enumerate(responses) if item.status_code == 200
|
||||
)
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password(passwords[winner], self.access.account.password_hash)
|
||||
)
|
||||
|
||||
def test_competing_issuance_leaves_exactly_one_usable_code(self):
|
||||
responses = self._race(
|
||||
"recovery/person",
|
||||
[{"current_password": "Owner-password", "identity_verified": True}] * 2,
|
||||
headers=self.access.headers(self.access.owner_session),
|
||||
locked_account="owner",
|
||||
)
|
||||
self.assertEqual([200, 200], [item.status_code for item in responses])
|
||||
codes = [item.json()["recovery_code"] for item in responses]
|
||||
self.assertEqual(2, len(set(codes)))
|
||||
self.access.db.expire_all()
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.access.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
[200, 400], sorted(self.access.recover(code).status_code for code in codes)
|
||||
)
|
||||
|
||||
def test_issuer_password_revocation_wins_paused_redemption(self):
|
||||
issued = self.access.issue()
|
||||
self.assertEqual(200, issued.status_code)
|
||||
code = issued.json()["recovery_code"]
|
||||
reached = threading.Event()
|
||||
proceed = threading.Event()
|
||||
real_authorized = auth.recovery_issuer_authorized
|
||||
|
||||
def paused_authorization(*args, **kwargs):
|
||||
reached.set()
|
||||
self.assertTrue(proceed.wait(timeout=35))
|
||||
return real_authorized(*args, **kwargs)
|
||||
|
||||
with patch.object(
|
||||
auth, "recovery_issuer_authorized", side_effect=paused_authorization
|
||||
):
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
redemption = executor.submit(
|
||||
self._post,
|
||||
"recover",
|
||||
{
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": "Must-not-replace-password",
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.assertTrue(reached.wait(timeout=10))
|
||||
changed = self._post(
|
||||
"change",
|
||||
{
|
||||
"current_password": "Owner-password",
|
||||
"new_password": "Owner-race-password",
|
||||
},
|
||||
self.access.headers(self.access.owner_session),
|
||||
)
|
||||
self.assertEqual(200, changed.status_code)
|
||||
finally:
|
||||
proceed.set()
|
||||
rejected = redemption.result(timeout=35)
|
||||
self.assertEqual(400, rejected.status_code)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("Initial-password", self.access.account.password_hash)
|
||||
)
|
||||
self.assertTrue(
|
||||
verify_password("Owner-race-password", self.access.owner.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user