1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 19:56:48 +00:00

feat(accounts): force a password change at the first login with the generated password

While data/admin_password exists and a user's password still matches it,
the login sets a session flag (checked once at login, not per request).
ForcePasswordChangeMiddleware then sends that user to the password-change
page for every view except change password, login and logout. Setting a
new password clears the flag and deletes data/admin_password. No schema
change; if ADMIN_PASSWORD was set, there is no file and nothing is forced.
This commit is contained in:
catborise 2026-10-02 23:55:31 +03:00
parent 40822e7184
commit 78852eb4c8
6 changed files with 114 additions and 6 deletions

View file

@ -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://<server-ip>`.

View file

@ -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)

21
accounts/middleware.py Normal file
View file

@ -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)

View file

@ -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())

View file

@ -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")

View file

@ -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",