mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +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:
parent
40287dbd2a
commit
401c648944
5 changed files with 47 additions and 4 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40
webvirtcloud/test_otp_bypass.py
Normal file
40
webvirtcloud/test_otp_bypass.py
Normal 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)
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue