mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +00:00
fix(accounts): first-login password change is stored, not in the session
The forced change of the generated admin password was a session flag set at login when the password still matched data/admin_password. A new session after the file was gone bypassed it. - UserAttributes.must_change_password (migration 0007) holds it; the admin created with a generated password gets it, and a password change clears it. ForcePasswordChangeMiddleware reads it. - A system check (accounts.E001) stops the server unless the middleware follows AuthenticationMiddleware; the README has the settings change. - If the generated password cannot be stored privately, provisioning stops without printing it and without creating the admin (one transaction for the user and its attributes). - The admin is provisioned on any real migrate run while no user exists, so running migrate again after a failure creates it; flush (plan=None) and rollbacks of accounts are skipped. - The post_migrate receivers check the migration state for the models they use, so a targeted migrate on a fresh database (e.g. migrate appsettings) no longer fails with 'no such table'.
This commit is contained in:
parent
3b8869c72f
commit
6dabd69445
10 changed files with 263 additions and 60 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.db.models.signals import post_migrate
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
def admin_password_path():
|
||||
|
|
@ -11,18 +11,16 @@ def admin_password_path():
|
|||
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).
|
||||
"""
|
||||
def _migrated(state_apps, model, field=None):
|
||||
"""post_migrate follows every migrate, also targeted ones and rollbacks:
|
||||
act only once the post-migration state has the model (and field)."""
|
||||
if state_apps is None:
|
||||
return False
|
||||
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
|
||||
fields = state_apps.get_model(model)._meta.get_fields()
|
||||
except LookupError:
|
||||
return False
|
||||
return field is None or any(f.name == field for f in fields)
|
||||
|
||||
|
||||
def apply_change_password(sender, **kwargs):
|
||||
|
|
@ -33,6 +31,8 @@ def apply_change_password(sender, **kwargs):
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, User
|
||||
|
||||
if not (_migrated(kwargs.get("apps"), "auth.Permission") and _migrated(kwargs.get("apps"), "auth.User")):
|
||||
return
|
||||
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
|
||||
print("\033[1m! \033[92mSHOW_PROFILE_EDIT_PASSWORD is found inside settings.py\033[0m")
|
||||
print("\033[1m* \033[92mApplying permission can_change_password for all users\033[0m")
|
||||
|
|
@ -53,8 +53,7 @@ def _store_generated_password(password):
|
|||
"""
|
||||
Write the generated admin password to data/admin_password (mode 0600)
|
||||
instead of printing it, so it does not end up in install logs or
|
||||
`docker logs` (ROADMAP O-02). Falls back to printing if the file cannot
|
||||
be written, so the admin is never locked out.
|
||||
`docker logs` (ROADMAP O-02). Abort provisioning if private storage fails.
|
||||
"""
|
||||
path = admin_password_path()
|
||||
try:
|
||||
|
|
@ -64,12 +63,15 @@ def _store_generated_password(password):
|
|||
if os.path.lexists(path):
|
||||
os.unlink(path)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
f.write(password + "\n")
|
||||
print(f"\033[1m* \033[93mGenerated admin password written to {path}\033[0m")
|
||||
except OSError:
|
||||
print(f"\033[1m* \033[93mGenerated random admin password: {password}\033[0m")
|
||||
except OSError as err:
|
||||
raise RuntimeError(
|
||||
f"Cannot securely store the generated admin password at {path}. "
|
||||
"Fix the data directory permissions and run migrate again."
|
||||
) from err
|
||||
|
||||
|
||||
def create_admin(sender, **kwargs):
|
||||
|
|
@ -83,35 +85,40 @@ def create_admin(sender, **kwargs):
|
|||
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
plan = kwargs.get("plan", [])
|
||||
for migration, rolled_back in plan:
|
||||
if (
|
||||
migration.app_label == "accounts"
|
||||
and migration.name == "0001_initial"
|
||||
and not rolled_back
|
||||
):
|
||||
if User.objects.count() == 0:
|
||||
is_testing = "test" in sys.argv
|
||||
admin_user = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
admin_pass = os.environ.get("ADMIN_PASSWORD")
|
||||
# Any real migrate run provisions while no user exists, so a run that
|
||||
# failed to store the password can simply be repeated. flush (used by
|
||||
# tests) sends plan=None.
|
||||
if kwargs.get("plan") is None:
|
||||
return
|
||||
state_apps = kwargs.get("apps")
|
||||
if not (_migrated(state_apps, "auth.User") and _migrated(state_apps, "accounts.UserAttributes", "must_change_password")):
|
||||
return
|
||||
if User.objects.exists():
|
||||
return
|
||||
|
||||
if not admin_pass:
|
||||
if is_testing:
|
||||
admin_pass = "admin"
|
||||
else:
|
||||
admin_pass = secrets.token_urlsafe(16)
|
||||
_store_generated_password(admin_pass)
|
||||
is_testing = "test" in sys.argv
|
||||
admin_user = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
admin_pass = os.environ.get("ADMIN_PASSWORD")
|
||||
generated_password = not admin_pass and not is_testing
|
||||
|
||||
print(f"\033[1m* \033[92mCreating default admin user '{admin_user}'\033[0m")
|
||||
admin = User.objects.create_superuser(admin_user, None, admin_pass)
|
||||
UserAttributes(
|
||||
user=admin,
|
||||
max_instances=-1,
|
||||
max_cpus=-1,
|
||||
max_memory=-1,
|
||||
max_disk_size=-1,
|
||||
).save()
|
||||
break
|
||||
if not admin_pass:
|
||||
if is_testing:
|
||||
admin_pass = "admin"
|
||||
else:
|
||||
admin_pass = secrets.token_urlsafe(16)
|
||||
_store_generated_password(admin_pass)
|
||||
|
||||
print(f"\033[1m* \033[92mCreating default admin user '{admin_user}'\033[0m")
|
||||
with transaction.atomic():
|
||||
admin = User.objects.create_superuser(admin_user, None, admin_pass)
|
||||
UserAttributes.objects.create(
|
||||
user=admin,
|
||||
must_change_password=generated_password,
|
||||
max_instances=-1,
|
||||
max_cpus=-1,
|
||||
max_memory=-1,
|
||||
max_disk_size=-1,
|
||||
)
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
|
|
@ -121,4 +128,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)
|
||||
from . import checks # noqa: F401
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue