1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/accounts/test_admin_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

92 lines
4.3 KiB
Python

"""The generated admin password must not be printed to the log."""
import io
import os
import stat
import tempfile
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import MagicMock, patch
from django.apps import apps
from django.contrib.auth import authenticate, get_user_model
from django.test import RequestFactory, TestCase, override_settings
from accounts.apps import create_admin
from accounts.models import UserAttributes
class GeneratedAdminPasswordTestCase(TestCase):
def _run_create_admin(self, base_dir, plan=None):
get_user_model().objects.all().delete()
if plan is None:
migration = MagicMock(app_label="accounts")
migration.name = "0001_initial"
plan = [(migration, False)]
out = io.StringIO()
env = {k: v for k, v in os.environ.items() if k not in ("ADMIN_PASSWORD", "ADMIN_USERNAME")}
with override_settings(BASE_DIR=Path(base_dir)), patch("sys.argv", ["manage.py", "migrate"]), patch.dict(
os.environ, env, clear=True
), redirect_stdout(out):
create_admin(sender=None, plan=plan, apps=apps)
return out.getvalue()
def test_password_is_written_to_a_private_file_not_printed(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "data").mkdir()
output = self._run_create_admin(tmp)
password_file = Path(tmp) / "data" / "admin_password"
self.assertTrue(password_file.exists())
password = password_file.read_text().strip()
self.assertEqual(stat.S_IMODE(password_file.stat().st_mode), 0o600)
self.assertNotIn(password, output)
self.assertIn(str(password_file), output)
self.assertIsNotNone(authenticate(RequestFactory().get("/"), username="admin", password=password))
self.assertTrue(UserAttributes.objects.get(user__username="admin").must_change_password)
def test_does_not_follow_a_planted_symlink(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "data").mkdir()
victim = Path(tmp) / "victim"
victim.write_text("original\n")
(Path(tmp) / "data" / "admin_password").symlink_to(victim)
self._run_create_admin(tmp)
password_file = Path(tmp) / "data" / "admin_password"
self.assertEqual(victim.read_text(), "original\n")
self.assertFalse(password_file.is_symlink())
self.assertEqual(stat.S_IMODE(password_file.stat().st_mode), 0o600)
def test_storage_failure_does_not_print_password_or_create_an_admin(self):
password = "review-dummy-generated-password"
output = io.StringIO()
with tempfile.TemporaryDirectory() as tmp, patch(
"secrets.token_urlsafe", return_value=password
), patch("accounts.apps.os.open", side_effect=PermissionError("read-only")), redirect_stdout(output):
with self.assertRaisesRegex(RuntimeError, "Cannot securely store"):
self._run_create_admin(tmp)
self.assertNotIn(password, output.getvalue())
self.assertFalse(get_user_model().objects.exists())
def test_rerunning_migrate_after_a_storage_failure_creates_the_admin(self):
with tempfile.TemporaryDirectory() as tmp:
with patch("accounts.apps.os.open", side_effect=PermissionError("read-only")), redirect_stdout(io.StringIO()):
with self.assertRaises(RuntimeError):
self._run_create_admin(tmp)
# The second migrate has nothing left to apply: an empty plan.
(Path(tmp) / "data").mkdir(exist_ok=True)
self._run_create_admin(tmp, plan=[])
self.assertTrue(get_user_model().objects.filter(username="admin").exists())
def test_flush_does_not_create_an_admin(self):
get_user_model().objects.all().delete()
create_admin(sender=None, plan=None, apps=apps)
self.assertFalse(get_user_model().objects.exists())
def test_migrating_another_app_before_auth_creates_nothing(self):
get_user_model().objects.all().delete()
state_without_auth = MagicMock()
state_without_auth.get_model.side_effect = LookupError
create_admin(sender=None, plan=[], apps=state_without_auth)
self.assertFalse(get_user_model().objects.exists())