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:
parent
40822e7184
commit
78852eb4c8
6 changed files with 114 additions and 6 deletions
|
|
@ -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>`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
21
accounts/middleware.py
Normal 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)
|
||||
57
accounts/test_first_login_password.py
Normal file
57
accounts/test_first_login_password.py
Normal 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())
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue