From 4433abd34b533b48643d74bd186f603271338194 Mon Sep 17 00:00:00 2001 From: level09 Date: Tue, 12 May 2026 18:48:52 +0300 Subject: [PATCH] fix(auth): clear force-reset flag when password is written outside web flow The force-password-reset Redis flag is cleared by Flask-Security's password_changed signal, which only fires through the web /change form. CLI password resets and admin user-edit writes bypassed the signal, leaving the flag set after a fresh password was written. The user would then be redirected to /change on every request indefinitely. Centralize password writes through User.set_password(), which hashes and clears the flag together. Update flask reset, reset-all-passwords, and User.from_json to use it. New-user creation paths are unchanged since no flag can exist for an unsaved user. --- enferno/commands.py | 10 +++------- enferno/user/models.py | 13 ++++++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/enferno/commands.py b/enferno/commands.py index 5674640ba..f5f8896a5 100644 --- a/enferno/commands.py +++ b/enferno/commands.py @@ -22,10 +22,7 @@ from enferno.utils.db_alignment_helpers import DBAlignmentChecker from enferno.utils.logging_utils import get_logger from sqlalchemy import text -from enferno.admin.models import Bulletin -from enferno.admin.models.DynamicField import DynamicField from enferno.admin.models.DynamicFormHistory import DynamicFormHistory -from enferno.utils.date_helper import DateHelper from enferno.utils.form_history_utils import record_form_history from enferno.utils.validation_utils import validate_password_policy @@ -255,7 +252,7 @@ def reset(username: str, password: str) -> None: except ValueError as e: click.echo(str(e)) return - user.password = hash_password(password) + user.set_password(password) user.save() click.echo("User password has been reset successfully.") logger.info("User password has been reset successfully.") @@ -303,7 +300,7 @@ def generate_password(length: int = 16) -> str: results.append((user.username, user.email, new_password)) if not dry_run: - user.password = hash_password(new_password) + user.set_password(new_password) user.set_security_reset_key() user.save() @@ -528,7 +525,6 @@ def fail(msg): fail("Redis not reachable") try: - from celery import current_app as celery_app from enferno.tasks import celery inspector = celery.control.inspect(timeout=2) @@ -771,7 +767,7 @@ def status() -> None: total_extracted = sum(s["count"] for s in status_map.values()) pending = total_media - total_extracted - click.echo(f"\nOCR Status Summary") + click.echo("\nOCR Status Summary") click.echo(f"{'─' * 40}") click.echo(f"Total media: {total_media:,}") click.echo(f"Pending (no OCR): {pending:,}") diff --git a/enferno/user/models.py b/enferno/user/models.py index 326fa0fac..fb9c75856 100644 --- a/enferno/user/models.py +++ b/enferno/user/models.py @@ -234,6 +234,17 @@ def unset_security_reset_key(self) -> None: key = f"{SECURITY_KEY_NAMESPACE}:{self.id}" rds.delete(key) + def set_password(self, password: str) -> None: + """Hash and set the user password, clearing any active force-reset flag. + + Centralizing this on the model keeps the force-reset Redis flag in sync + with the stored hash, regardless of whether the password is written via + a CLI command or the admin UI. The web /change flow continues to clear + the flag via the `password_changed` signal. + """ + self.password = hash_password(password) + self.unset_security_reset_key() + def roles_in(self, roles: list) -> bool: chk = [self.has_role(r) for r in roles] return any(chk) @@ -354,7 +365,7 @@ def from_json(self, item: dict) -> "User": # check password is not empty password = item.get("password") if password: - self.password = hash_password(password) + self.set_password(password) self.name = item.get("name")