1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00
webvirtcloud/webvirtcloud/test_otp_bypass.py
catborise 401c648944 fix(webvirtcloud): the API and browsable login cannot bypass OTP with a password
OTP (OTP_ENABLED) was enforced only by the HTML login form, but two other
doors let an enrolled user authenticate with a password and no token, so a
leaked password defeated the second factor. Both were reproduced with a
confirmed TOTP device: /api-auth/login/ (DRF's browsable-API login) created a
site-wide session, and DRF's default BasicAuthentication let every /api/v1/
endpoint accept HTTP Basic credentials.

The api-auth/ include is removed, so the only interactive login is the
OTP-aware form at /accounts/login/; the browsable API still works with the
session that form sets. REST_FRAMEWORK now pins DEFAULT_AUTHENTICATION_CLASSES
to SessionAuthentication only, so the API requires a session and no longer
accepts a password over HTTP Basic. A session therefore implies the OTP factor
was met. Password-based programmatic API access is intentionally dropped.

The authorization matrix drops its rest_framework:login/logout allowlist
entries (the routes are gone). New tests assert the browsable login route is
removed, HTTP Basic is rejected on the API, Basic is not a default auth class,
and a session still reaches the API. Verified live with OTP_ENABLED that
/api-auth/login/ is 404 and an API Basic request is 403.
2026-10-07 09:57:40 +03:00

40 lines
1.8 KiB
Python

"""OTP must be the only interactive login door. The browsable-API login
(/api-auth/login/) is removed and HTTP Basic auth is off, so a session comes
only from the OTP-aware login form and the API cannot be reached with a
password alone, which would skip OTP."""
import base64
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import NoReverseMatch, reverse
from rest_framework.settings import api_settings
class BasicAuthDisabledTestCase(TestCase):
def test_session_is_the_only_default_authentication(self):
# exactly SessionAuthentication: not Basic (a password-only door) and
# not Token/JWT either, which would be another password-free door
names = [c.__name__ for c in api_settings.DEFAULT_AUTHENTICATION_CLASSES]
self.assertEqual(names, ["SessionAuthentication"])
def test_api_rejects_http_basic_credentials(self):
User = get_user_model()
User.objects.create_superuser("api-basic", "api-basic@example.com", "pw-123")
creds = base64.b64encode(b"api-basic:pw-123").decode()
response = Client().get("/api/v1/instances/", HTTP_AUTHORIZATION=f"Basic {creds}")
self.assertIn(response.status_code, (401, 403))
def test_api_works_with_a_session(self):
User = get_user_model()
user = User.objects.create_superuser("api-session", "api-session@example.com", "pw-123")
client = Client()
client.force_login(user)
self.assertEqual(client.get("/api/v1/instances/").status_code, 200)
class BrowsableApiLoginRemovedTestCase(TestCase):
def test_the_browsable_api_login_route_is_gone(self):
with self.assertRaises(NoReverseMatch):
reverse("rest_framework:login")
self.assertEqual(Client().get("/api-auth/login/").status_code, 404)