From 401c648944a016a94d2b73701a1397371bb0fc0e Mon Sep 17 00:00:00 2001 From: catborise Date: Wed, 7 Oct 2026 09:18:32 +0300 Subject: [PATCH] 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. --- README.md | 2 ++ webvirtcloud/settings.py.template | 5 +++ webvirtcloud/test_authorization_matrix.py | 3 -- webvirtcloud/test_otp_bypass.py | 40 +++++++++++++++++++++++ webvirtcloud/urls.py | 1 - 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 webvirtcloud/test_otp_bypass.py diff --git a/README.md b/README.md index 10bc23d4..813bbf33 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,8 @@ The template is not copied over an existing settings file during an upgrade. Failed logins are limited with `django-axes` (installed from `conf/requirements.txt`). Copy the `axes` entries of `webvirtcloud/settings.py.template` into your `webvirtcloud/settings.py`: `"axes"` in `INSTALLED_APPS`, `"axes.middleware.AxesMiddleware"` at the end of `MIDDLEWARE`, `"axes.backends.AxesStandaloneBackend"` first in `AUTHENTICATION_BACKENDS`, and the `AXES_*` settings; then run `migrate`. The number of failed logins and the lock time are on the Settings page; unlock a user with `python3 manage.py axes_reset_username `. Behind a proxy other than the bundled nginx, the proxy must set `X-Real-IP` to the client address and its address must be listed in `LOGIN_TRUSTED_PROXIES`. +OTP, when enabled, is enforced only by the login form, so close the two doors that skip it. Delete the `path('api-auth/', include('rest_framework.urls', ...))` line from your `webvirtcloud/urls.py` (the DRF browsable-API login created a password-only session), and add `"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"]` to the `REST_FRAMEWORK` block in your `webvirtcloud/settings.py` (without it the API accepts HTTP Basic credentials, a password-only bypass). The API then needs a session from the login form; password-based programmatic access no longer works. After updating settings, end any sessions opened through the old doors so they do not keep OTP-free access (empty the `django_session` table, e.g. `python3 manage.py shell -c "from django.contrib.sessions.models import Session; Session.objects.all().delete()"`); everyone signs in again. OTP is per user: a user without a confirmed device signs in with the password alone. + Migration: where the hosts cannot resolve each other's host names, set each compute's **Migration address** (the compute's edit page) to the IP the other hosts reach it on for migrations. Define shared storage as a storage pool on every host: a VM migrates only when its disk, backing and ISO files and block devices are volumes of the destination's pools (network disks are not checked). The "VM Clone Auto Migrate" setting is removed (migration `appsettings.0015`); a clone stays on the host of the VM it was made from. Migration `accounts.0007` transfers the first-login requirement from any existing diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index c2d2d54f..61dd8803 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -315,6 +315,11 @@ OTP_ENABLED = False # REST Framework & OpenAPI (drf-spectacular) REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + # Session only: a password alone (HTTP Basic) must not reach the API, as it + # would skip the OTP second factor the login form enforces. + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], } SPECTACULAR_SETTINGS = { diff --git a/webvirtcloud/test_authorization_matrix.py b/webvirtcloud/test_authorization_matrix.py index 88fd4170..7a289dc0 100644 --- a/webvirtcloud/test_authorization_matrix.py +++ b/webvirtcloud/test_authorization_matrix.py @@ -38,8 +38,6 @@ ALLOWED_FOR_ANY_USER = { "accounts:change_password": "the user's own password; new users get the permission by default", "accounts:ssh_key_create": "the user's own SSH keys", "set_language": "UI language switch", - "rest_framework:login": "DRF browsable API login", - "rest_framework:logout": "DRF browsable API logout", "console": "renders an error without a valid token; access is checked per VM", "ds_openstack_index": "cloud-init datasource, fetched by VMs", "ds_openstack_metadata": "cloud-init datasource, fetched by VMs", @@ -59,7 +57,6 @@ ALLOWED_FOR_ANY_USER = { ALLOWED_FOR_ANONYMOUS = { "accounts:login": "login page", "accounts:email_otp": "part of the login flow (its own issues are tracked separately)", - "rest_framework:login": "DRF browsable API login page", "ds_openstack_index": "cloud-init datasource, fetched by VMs that do not log in", "ds_openstack_metadata": "cloud-init datasource, fetched by VMs that do not log in", "ds_openstack_userdata": "cloud-init datasource, fetched by VMs that do not log in", diff --git a/webvirtcloud/test_otp_bypass.py b/webvirtcloud/test_otp_bypass.py new file mode 100644 index 00000000..805c2299 --- /dev/null +++ b/webvirtcloud/test_otp_bypass.py @@ -0,0 +1,40 @@ +"""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) diff --git a/webvirtcloud/urls.py b/webvirtcloud/urls.py index 12180424..27ddd4f7 100644 --- a/webvirtcloud/urls.py +++ b/webvirtcloud/urls.py @@ -31,7 +31,6 @@ urlpatterns = [ path("instances/", include("instances.urls")), path("i18n/", include("django.conf.urls.i18n")), path("logs/", include("logs.urls")), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('api/v1/', include("webvirtcloud.urls-api")), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), re_path(r"^swagger\.(?Pjson|yaml)$", SpectacularAPIView.as_view(), name="schema-json"),