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"),