diff --git a/README.md b/README.md index 0dd0877c..34daae0b 100644 --- a/README.md +++ b/README.md @@ -769,6 +769,7 @@ What an update from an older version changes for operators and users: - Failed logins lock a user (django-axes). The limit and lock time are on the Settings page; unlock with `python3 manage.py axes_reset_username `. - Changing one's own password is the "Can change password" permission (per user or group; new users have it); `SHOW_PROFILE_EDIT_PASSWORD` is gone. - The admin's first-login password change survives losing `data/admin_password` (migration `accounts.0007`); run `migrate` as a user who can read that file. +- An `admin` account that still has the password `admin` (the first user of versions up to v2.0.0) must change it at its next login, and its API tokens are refused until then (migration `accounts.0013`). - A `settings.py` kept from an older version must match the template (apps, middleware, the SQLite `OPTIONS`, the REST framework, knox, axes and password settings): rebuild it from the template and carry your own values over, as step 2 of the update does. - The VNC console connects to `/novncd/` on the panel's own address through nginx, and the serial console to `/socket.io/`: they work on HTTPS and behind another proxy without settings. `WS_PUBLIC_PORT`, `WS_PUBLIC_HOST`, `WS_PUBLIC_PATH`, `WS_CERT` and `SOCKETIO_PUBLIC_HOST`/`_PORT`/`_PATH` are gone; remove them from `docker-compose.override.yml`, `/etc/default/webvirtcloud` or a kept `settings.py`, set `WS_HOST = "127.0.0.1"` in a kept `settings.py`, and close port 6080 in the firewall. - Installer and manual installations run the panel as systemd units (`webvirt-cloud`, `webvirt-novncd`, `webvirt-socketiod`, `webvirt-worker`, `webvirt-backup.timer`) and computes run `webvirt-gstfsd`, instead of supervisor; see [Moving from supervisor to systemd](#moving-from-supervisor-to-systemd). Service settings go into `/etc/default/webvirtcloud`, service output into the journal (`journalctl -u webvirt-cloud`); the app log stays in `data/`. novncd and socketiod run sandboxed (read-only system directories, no `/home`). diff --git a/accounts/migrations/0013_default_admin_password.py b/accounts/migrations/0013_default_admin_password.py new file mode 100644 index 00000000..f22fde1e --- /dev/null +++ b/accounts/migrations/0013_default_admin_password.py @@ -0,0 +1,19 @@ +from django.conf import settings +from django.contrib.auth.hashers import check_password +from django.db import migrations + + +def flag_default_admin_password(apps, schema_editor): + """Versions up to v2.0.0 created the first user as admin/admin. An + installation that never changed it must replace it at the next login.""" + alias = schema_editor.connection.alias + User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) + Attributes = apps.get_model("accounts", "UserAttributes") + for user in User.objects.using(alias).filter(username="admin"): + if check_password("admin", user.password): + Attributes.objects.using(alias).update_or_create(user_id=user.pk, defaults={"must_change_password": True}) + + +class Migration(migrations.Migration): + dependencies = [("accounts", "0012_user_instance_constraint")] + operations = [migrations.RunPython(flag_default_admin_password, migrations.RunPython.noop)] diff --git a/accounts/tests/test_first_login_password.py b/accounts/tests/test_first_login_password.py index 3ae0671e..5a7a9dfc 100644 --- a/accounts/tests/test_first_login_password.py +++ b/accounts/tests/test_first_login_password.py @@ -114,3 +114,36 @@ class FirstLoginPasswordChangeTestCase(TestCase): 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) + + +class DefaultAdminPasswordMigrationTestCase(TestCase): + """An installation of a version up to v2.0.0 still signed in as admin/admin.""" + + def admin_with(self, password): + admin, _ = get_user_model().objects.get_or_create(username="admin", defaults={"is_superuser": True}) + admin.set_password(password) + admin.save() + UserAttributes.objects.filter(user=admin).delete() # the migration also makes them + return admin + + def migrate(self): + import importlib + from types import SimpleNamespace + from django.apps import apps + from django.db import connection + migration = importlib.import_module("accounts.migrations.0013_default_admin_password") + migration.flag_default_admin_password(apps, SimpleNamespace(connection=connection)) + + def test_the_default_admin_password_must_be_changed(self): + admin = self.admin_with("admin") + self.migrate() + self.assertTrue(UserAttributes.objects.get(user=admin).must_change_password) + self.client.force_login(admin) + self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"), + fetch_redirect_response=False) + + def test_a_changed_admin_password_is_left_alone(self): + admin = self.admin_with("Changed-pw-2026") + UserAttributes.objects.create(user=admin) + self.migrate() + self.assertFalse(UserAttributes.objects.get(user=admin).must_change_password)