1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00
webvirtcloud/accounts/test_first_login_password.py
catborise c2fea2aa39 feat(accounts): failed logins lock a user for the address they come from
Nothing limited failed logins: a password could be guessed at the speed
the server answers. django-axes (8.3.1, Django 5.2, Python 3.10-3.13)
now counts them.

- A user is locked for the address the failed logins come from
  (username and IP together), so an attacker cannot lock someone out
  from elsewhere and users behind one NAT address do not share a lock.
  A successful login resets the count. axes sits in front of every
  authentication: the login page with or without OTP, LDAP and the API.
- The number of failed logins (default 5) and the lock time in minutes
  (default 15) are on the Settings page (LOGIN_FAILURE_LIMIT,
  LOGIN_LOCKOUT_MINUTES, added by migration appsettings.0014) and apply
  at once; an invalid value falls back to the default. Unlock with
  "manage.py axes_reset_username <username>".
- Behind the bundled nginx every request comes from 127.0.0.1; the
  client address is read from X-Real-IP, which nginx sets, but only for
  requests from a loopback address or from LOGIN_TRUSTED_PROXIES, so a
  forged header does not escape a lock.

settings.py.template gets the app, middleware, backend and AXES_*
settings; README tells existing installations what to copy. Tests that
call client.login now pass a request, which the axes backend needs.
The new tests fail with axes disabled.
2026-10-06 11:05:03 +03:00

116 lines
6 KiB
Python

"""The generated admin password must be replaced at first login."""
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from unittest.mock import patch
from accounts.models import UserAttributes
GENERATED = "Generated-Pass-123"
class FirstLoginPasswordChangeTestCase(TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.password_file = Path(self.tmp.name) / "data" / "admin_password"
self.password_file.parent.mkdir()
self.password_file.write_text(GENERATED + "\n")
settings_override = override_settings(BASE_DIR=Path(self.tmp.name))
settings_override.enable()
self.addCleanup(settings_override.disable)
self.admin = get_user_model().objects.create_superuser(
username="firstadmin", email="firstadmin@example.com", password=GENERATED
)
UserAttributes.objects.create(user=self.admin, must_change_password=True)
def test_login_with_generated_password_redirects_every_page_to_change_form(self):
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
for url in (reverse("index"), reverse("instances:index"), reverse("accounts:profile")):
with self.subTest(url=url):
response = self.client.get(url)
self.assertRedirects(
response, reverse("accounts:change_password"), fetch_redirect_response=False
)
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 200)
def test_changing_the_password_lifts_the_redirect_and_deletes_the_file(self):
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
self.client.post(
reverse("accounts:change_password"),
{
"old_password": GENERATED,
"new_password1": "A-new-strong-pass-456",
"new_password2": "A-new-strong-pass-456",
},
)
self.assertFalse(self.password_file.exists())
self.assertFalse(UserAttributes.objects.get(user=self.admin).must_change_password)
response = self.client.get(reverse("instances:index"))
self.assertEqual(response.status_code, 200)
def test_user_with_another_password_is_not_forced(self):
get_user_model().objects.create_superuser(
username="otheradmin", email="otheradmin@example.com", password="Other-pass-789"
)
self.client.login(request=RequestFactory().get("/"), username="otheradmin", password="Other-pass-789")
self.assertEqual(self.client.get(reverse("instances:index")).status_code, 200)
self.assertTrue(self.password_file.exists())
def test_missing_file_and_a_new_session_do_not_bypass_enforcement(self):
self.password_file.unlink()
self.client.force_login(self.admin)
self.assertRedirects(
self.client.get(reverse("accounts:profile")),
reverse("accounts:change_password"), fetch_redirect_response=False,
)
def test_unreadable_password_file_is_not_used_during_login(self):
with patch("accounts.apps.admin_password_path", side_effect=AssertionError("must not read file")):
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
self.assertRedirects(
self.client.get(reverse("accounts:profile")),
reverse("accounts:change_password"), fetch_redirect_response=False,
)
def test_cleanup_failure_does_not_undo_a_successful_password_change(self):
self.client.force_login(self.admin)
with patch("accounts.views.os.remove", side_effect=PermissionError("read-only")):
response = self.client.post(reverse("accounts:change_password"), {
"old_password": GENERATED, "new_password1": "A-new-strong-pass-456",
"new_password2": "A-new-strong-pass-456",
})
self.assertEqual(response.status_code, 302)
self.assertFalse(UserAttributes.objects.get(user=self.admin).must_change_password)
self.admin.refresh_from_db()
self.assertTrue(self.admin.check_password("A-new-strong-pass-456"))
self.assertEqual(self.client.get(reverse("accounts:profile")).status_code, 200)
def test_missing_middleware_is_reported_by_the_upgrade_check(self):
from accounts.checks import password_change_middleware_check
with override_settings(MIDDLEWARE=["django.contrib.auth.middleware.AuthenticationMiddleware"]):
errors = password_change_middleware_check(None)
self.assertEqual([error.id for error in errors], ["accounts.E001"])
def test_legacy_migration_flags_only_the_matching_account(self):
import importlib
from types import SimpleNamespace
from django.apps import apps
from django.db import connection
migration = importlib.import_module("accounts.migrations.0007_userattributes_must_change_password")
UserAttributes.objects.filter(user=self.admin).update(must_change_password=False)
other = get_user_model().objects.create_superuser("legacy_other", "", "other-password")
migration.flag_legacy_generated_password(apps, SimpleNamespace(connection=connection))
self.assertTrue(UserAttributes.objects.get(user=self.admin).must_change_password)
self.assertFalse(UserAttributes.objects.filter(user=other, must_change_password=True).exists())
def test_legacy_migration_fails_if_password_file_cannot_be_read(self):
import importlib
migration = importlib.import_module("accounts.migrations.0007_userattributes_must_change_password")
with patch("pathlib.Path.read_text", side_effect=PermissionError("unreadable")):
with self.assertRaisesRegex(RuntimeError, "run migrate as its owner"):
migration.flag_legacy_generated_password(None, None)