1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

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.
This commit is contained in:
catborise 2026-10-07 09:18:32 +03:00
parent 40287dbd2a
commit 401c648944
5 changed files with 47 additions and 4 deletions

View file

@ -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 <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

View file

@ -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 = {

View file

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

View file

@ -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)

View file

@ -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\.(?P<format>json|yaml)$", SpectacularAPIView.as_view(), name="schema-json"),