diff --git a/README.md b/README.md index 245614ac..7c739472 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ sudo zypper install -y dmidecode && sudo systemctl restart libvirtd ### Default Credentials -The first `python3 manage.py migrate` on an empty database creates a superuser named `admin` with a random password, written to `data/admin_password` (mode 0600). In Docker: `docker compose exec webvirtcloud cat data/admin_password`. +The first `python3 manage.py migrate` on an empty database creates a superuser named `admin` with a random password, written to `data/admin_password` (mode 0600). In Docker: `docker compose exec webvirtcloud cat data/admin_password`. At the first login with it you must set a new password; the file is then deleted. Set `ADMIN_USERNAME` and/or `ADMIN_PASSWORD` in the environment before that first migrate to choose them yourself. Then sign in at `http://`. diff --git a/accounts/apps.py b/accounts/apps.py index 45b2613d..cdaedf70 100644 --- a/accounts/apps.py +++ b/accounts/apps.py @@ -1,7 +1,30 @@ +import os + from django.apps import AppConfig +from django.contrib.auth.signals import user_logged_in from django.db.models.signals import post_migrate +def admin_password_path(): + from django.conf import settings + + return os.path.join(str(settings.BASE_DIR), "data", "admin_password") + + +def flag_generated_password(sender, request, user, **kwargs): + """ + While a user still has the generated admin password, make them set a new + one before using the panel (ROADMAP O-17; see ForcePasswordChangeMiddleware). + """ + try: + with open(admin_password_path()) as f: + generated = f.read().strip() + except OSError: + return + if generated and user.check_password(generated): + request.session["must_change_password"] = True + + def apply_change_password(sender, **kwargs): """ Apply new change_password permission for all users @@ -33,11 +56,7 @@ def _store_generated_password(password): `docker logs` (ROADMAP O-02). Falls back to printing if the file cannot be written, so the admin is never locked out. """ - import os - - from django.conf import settings - - path = os.path.join(str(settings.BASE_DIR), "data", "admin_password") + path = admin_password_path() try: os.makedirs(os.path.dirname(path), exist_ok=True) # Never write through a pre-existing file or symlink (this runs as @@ -102,3 +121,4 @@ class AccountsConfig(AppConfig): def ready(self): post_migrate.connect(create_admin, sender=self) post_migrate.connect(apply_change_password, sender=self) + user_logged_in.connect(flag_generated_password) diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 00000000..445794d4 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,21 @@ +from django.shortcuts import redirect +from django.urls import Resolver404, resolve + +ALLOWED_VIEWS = {"accounts:change_password", "accounts:logout", "accounts:login"} + + +class ForcePasswordChangeMiddleware: + """Send users who still have the generated admin password to the change form.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.session.get("must_change_password"): + try: + view_name = resolve(request.path_info).view_name + except Resolver404: + view_name = None + if view_name not in ALLOWED_VIEWS: + return redirect("accounts:change_password") + return self.get_response(request) diff --git a/accounts/test_first_login_password.py b/accounts/test_first_login_password.py new file mode 100644 index 00000000..a6cc1e03 --- /dev/null +++ b/accounts/test_first_login_password.py @@ -0,0 +1,57 @@ +"""ROADMAP O-17: 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 TestCase, override_settings +from django.urls import reverse + +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 + ) + + def test_login_with_generated_password_redirects_every_page_to_change_form(self): + self.client.login(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(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()) + 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(username="otheradmin", password="Other-pass-789") + self.assertEqual(self.client.get(reverse("instances:index")).status_code, 200) + self.assertTrue(self.password_file.exists()) diff --git a/accounts/views.py b/accounts/views.py index 2e6bc3af..6154892b 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,6 @@ +import contextlib +import os + from admin.decorators import superuser_only from django.conf import settings from django.contrib import messages @@ -10,6 +13,8 @@ from django.http import HttpResponseRedirect from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_POST + +from accounts.apps import admin_password_path from instances.models import Instance from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm @@ -114,6 +119,10 @@ def change_password(request): if form.is_valid(): user = form.save() update_session_auth_hash(request, user) # Important! + if request.session.pop("must_change_password", False): + # The generated first-install password is no longer valid. + with contextlib.suppress(FileNotFoundError): + os.remove(admin_password_path()) messages.success(request, _("Password Changed")) return redirect("accounts:profile") diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index 0e5da068..d77cf51c 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -79,6 +79,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django_otp.middleware.OTPMiddleware", "login_required.middleware.LoginRequiredMiddleware", + "accounts.middleware.ForcePasswordChangeMiddleware", "django.contrib.auth.middleware.RemoteUserMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",