Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
298 lines
12 KiB
Python
298 lines
12 KiB
Python
import json
|
|
|
|
import pyotp
|
|
import pytest
|
|
from django.utils import timezone
|
|
from rest_framework import status
|
|
from rest_framework.test import APIClient
|
|
|
|
from infrasynth.security.models import TwoFactorConfig
|
|
from infrasynth.security.two_factor.services import RecoveryCodeService, TOTPService
|
|
|
|
|
|
@pytest.fixture
|
|
def totp_service():
|
|
return TOTPService()
|
|
|
|
|
|
@pytest.fixture
|
|
def recovery_service():
|
|
return RecoveryCodeService()
|
|
|
|
|
|
class TestTOTPService:
|
|
def test_generate_secret_is_base32(self, totp_service):
|
|
secret = totp_service.generate_secret()
|
|
assert len(secret) >= 16
|
|
pyotp.TOTP(secret).now()
|
|
|
|
def test_generate_secret_unique(self, totp_service):
|
|
assert totp_service.generate_secret() != totp_service.generate_secret()
|
|
|
|
def test_provisioning_uri_contains_issuer_and_email(self, totp_service):
|
|
uri = totp_service.get_provisioning_uri("SECRETBASE32", "user@example.com")
|
|
assert "user%40example.com" in uri
|
|
assert "InfraSynth" in uri
|
|
|
|
def test_qr_base64_is_valid_png(self, totp_service):
|
|
import base64
|
|
|
|
qr = totp_service.generate_qr_base64("SECRETBASE32", "user@example.com")
|
|
raw = base64.b64decode(qr)
|
|
assert raw[:8] == b"\x89PNG\r\n\x1a\n"
|
|
|
|
def test_verify_correct_code(self, totp_service):
|
|
secret = totp_service.generate_secret()
|
|
code = pyotp.TOTP(secret).now()
|
|
assert totp_service.verify(secret, code) is True
|
|
|
|
def test_verify_wrong_code(self, totp_service):
|
|
secret = totp_service.generate_secret()
|
|
assert totp_service.verify(secret, "000000") is False
|
|
|
|
def test_encrypt_decrypt_roundtrip(self, totp_service):
|
|
secret = "MYTESTSECRET"
|
|
encrypted = totp_service.encrypt_secret(secret)
|
|
assert encrypted != secret
|
|
assert totp_service.decrypt_secret(encrypted) == secret
|
|
|
|
|
|
class TestRecoveryCodeService:
|
|
def test_generate_codes_count(self, recovery_service):
|
|
codes = recovery_service.generate_codes()
|
|
assert len(codes) == 8
|
|
assert len(set(codes)) == 8
|
|
|
|
def test_codes_have_prefix(self, recovery_service):
|
|
codes = recovery_service.generate_codes()
|
|
assert all(c.startswith("RC-") for c in codes)
|
|
|
|
def test_encrypt_decrypt_roundtrip(self, recovery_service):
|
|
codes = ["RC-AAA-BBB"]
|
|
encrypted = recovery_service.encrypt_codes(codes)
|
|
assert encrypted != json.dumps(codes)
|
|
assert recovery_service.decrypt_codes(encrypted) == codes
|
|
|
|
def test_verify_code(self, recovery_service):
|
|
codes = recovery_service.generate_codes()
|
|
encrypted = recovery_service.encrypt_codes(codes)
|
|
assert recovery_service.verify_code(codes[0], encrypted) is True
|
|
assert recovery_service.verify_code("RC-WRONG", encrypted) is False
|
|
|
|
def test_verify_corrupted_data_returns_false(self, recovery_service):
|
|
assert recovery_service.verify_code("RC-X", "garbage-data") is False
|
|
|
|
def test_remove_used_code(self, recovery_service):
|
|
codes = recovery_service.generate_codes()
|
|
encrypted = recovery_service.encrypt_codes(codes)
|
|
used = codes[0]
|
|
updated = recovery_service.remove_used_code(used, encrypted)
|
|
remaining = recovery_service.decrypt_codes(updated)
|
|
assert used not in remaining
|
|
assert len(remaining) == len(codes) - 1
|
|
|
|
def test_remove_nonexistent_returns_none(self, recovery_service):
|
|
codes = recovery_service.generate_codes()
|
|
encrypted = recovery_service.encrypt_codes(codes)
|
|
assert recovery_service.remove_used_code("RC-NOPE", encrypted) is None
|
|
|
|
def test_remove_from_corrupted_returns_none(self, recovery_service):
|
|
assert recovery_service.remove_used_code("RC-X", "garbage") is None
|
|
|
|
|
|
@pytest.fixture
|
|
def two_factor_client(user):
|
|
client = APIClient()
|
|
client.force_authenticate(user=user)
|
|
return client
|
|
|
|
|
|
class TestTwoFactorLoginFlow:
|
|
@staticmethod
|
|
def _enable(user):
|
|
secret = TOTPService().generate_secret()
|
|
rcs = RecoveryCodeService()
|
|
TwoFactorConfig.objects.create(
|
|
user=user,
|
|
is_enabled=True,
|
|
is_configured=True,
|
|
secret_key_encrypted=TOTPService().encrypt_secret(secret),
|
|
recovery_codes_encrypted=rcs.encrypt_codes(rcs.generate_codes()),
|
|
)
|
|
return secret
|
|
|
|
def test_login_challenges_for_second_factor(self, db, user):
|
|
self._enable(user)
|
|
client = APIClient()
|
|
resp = client.post(
|
|
"/api/v1/auth/login/",
|
|
{"username": "testuser", "password": "testpass123"},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["requires2fa"] is True
|
|
assert "access_token" not in resp.cookies
|
|
|
|
def test_verify_after_login_issues_tokens(self, db, user):
|
|
secret = self._enable(user)
|
|
client = APIClient()
|
|
client.post("/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json")
|
|
resp = client.post(
|
|
"/api/v1/auth/2fa/verify/",
|
|
{"code": pyotp.TOTP(secret).now()},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert "access_token" in resp.cookies
|
|
|
|
def test_no_second_factor_logs_in_directly(self, db, user):
|
|
client = APIClient()
|
|
resp = client.post(
|
|
"/api/v1/auth/login/",
|
|
{"username": "testuser", "password": "testpass123"},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert "access_token" in resp.cookies
|
|
|
|
|
|
class TestTwoFactorSetup:
|
|
def test_setup_returns_secret_and_qr(self, two_factor_client):
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/setup/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
data = resp.json()
|
|
assert "secret" in data
|
|
assert "qr_base64" in data
|
|
assert "provisioning_uri" in data
|
|
|
|
def test_setup_requires_auth(self, api_client):
|
|
resp = api_client.post("/api/v1/auth/2fa/setup/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestTwoFactorVerifySetup:
|
|
def test_verify_setup_complete_flow(self, two_factor_client, user):
|
|
setup = two_factor_client.post("/api/v1/auth/2fa/setup/")
|
|
secret = setup.json()["secret"]
|
|
code = pyotp.TOTP(secret).now()
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify-setup/", {"code": code}, format="json")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert len(resp.json()["recovery_codes"]) == 8
|
|
|
|
config = TwoFactorConfig.objects.get(user=user)
|
|
assert config.is_enabled is True
|
|
assert config.is_configured is True
|
|
assert config.secret_key_encrypted
|
|
|
|
def test_verify_setup_wrong_code(self, two_factor_client):
|
|
two_factor_client.post("/api/v1/auth/2fa/setup/")
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify-setup/", {"code": "000000"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_verify_setup_without_session(self, two_factor_client):
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify-setup/", {"code": "123456"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestTwoFactorDisable:
|
|
def test_disable_without_config(self, two_factor_client):
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/disable/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
|
|
def test_disable_with_config(self, two_factor_client, user):
|
|
TwoFactorConfig.objects.create(
|
|
user=user,
|
|
is_enabled=True,
|
|
is_configured=True,
|
|
secret_key_encrypted="enc",
|
|
recovery_codes_encrypted="enc",
|
|
)
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/disable/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
user.two_factor_config.refresh_from_db()
|
|
assert user.two_factor_config.is_enabled is False
|
|
assert user.two_factor_config.secret_key_encrypted is None
|
|
|
|
|
|
class TestTwoFactorVerifyAndRecovery:
|
|
@pytest.fixture
|
|
def configured_user(self, db, two_factor_client, user):
|
|
secret = TOTPService().generate_secret()
|
|
rcs = RecoveryCodeService()
|
|
TwoFactorConfig.objects.create(
|
|
user=user,
|
|
is_enabled=True,
|
|
is_configured=True,
|
|
secret_key_encrypted=TOTPService().encrypt_secret(secret),
|
|
recovery_codes_encrypted=rcs.encrypt_codes(rcs.generate_codes()),
|
|
)
|
|
return user, secret
|
|
|
|
def test_verify_requires_pre_auth_session(self, two_factor_client, configured_user):
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": "123456"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_recovery_requires_pre_auth_session(self, two_factor_client, configured_user):
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/recovery/", {"recovery_code": "RC-X"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_verify_with_correct_code(self, two_factor_client, configured_user):
|
|
user, secret = configured_user
|
|
session = two_factor_client.session
|
|
session["_2fa_user_id"] = user.pk
|
|
session["_2fa_pre_auth_token"] = "pre-auth-token"
|
|
session["_2fa_started_at"] = timezone.now().isoformat()
|
|
session.save()
|
|
|
|
code = pyotp.TOTP(secret).now()
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": code}, format="json")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert "access_token" in resp.cookies
|
|
|
|
def test_verify_with_wrong_code(self, two_factor_client, configured_user):
|
|
user, secret = configured_user
|
|
session = two_factor_client.session
|
|
session["_2fa_user_id"] = user.pk
|
|
session.save()
|
|
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": "000000"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_verify_without_2fa_config(self, two_factor_client, user):
|
|
session = two_factor_client.session
|
|
session["_2fa_user_id"] = user.pk
|
|
session.save()
|
|
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/verify/", {"code": "123456"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_recovery_with_valid_code(self, two_factor_client, configured_user):
|
|
user, secret = configured_user
|
|
config = TwoFactorConfig.objects.get(user=user)
|
|
recovery_codes = RecoveryCodeService().decrypt_codes(config.recovery_codes_encrypted)
|
|
session = two_factor_client.session
|
|
session["_2fa_user_id"] = user.pk
|
|
session["_2fa_pre_auth_token"] = "pre-auth-token"
|
|
session["_2fa_started_at"] = timezone.now().isoformat()
|
|
session.save()
|
|
|
|
resp = two_factor_client.post(
|
|
"/api/v1/auth/2fa/recovery/",
|
|
{"recovery_code": recovery_codes[0]},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert "access_token" in resp.cookies
|
|
|
|
config.refresh_from_db()
|
|
remaining = RecoveryCodeService().decrypt_codes(config.recovery_codes_encrypted)
|
|
assert recovery_codes[0] not in remaining
|
|
|
|
def test_recovery_with_invalid_code(self, two_factor_client, configured_user):
|
|
user, secret = configured_user
|
|
session = two_factor_client.session
|
|
session["_2fa_user_id"] = user.pk
|
|
session.save()
|
|
|
|
resp = two_factor_client.post("/api/v1/auth/2fa/recovery/", {"recovery_code": "RC-INVALID"}, format="json")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|