mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
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.
92 lines
4.3 KiB
Python
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())
|