diff --git a/doajtest/unit/test_Account_password_legacy.py b/doajtest/unit/test_Account_password_legacy.py new file mode 100644 index 0000000000..634f4ce9b7 --- /dev/null +++ b/doajtest/unit/test_Account_password_legacy.py @@ -0,0 +1,60 @@ +import hashlib +from doajtest.helpers import DoajTestCase +from portality import models + + +class TestAccountPasswordLegacy(DoajTestCase): + def test_legacy_password_upgrade_plain_sha1(self): + """ Test that a plain SHA1 hash (legacy) is verified and upgraded. """ + password = "password123" + # Create legacy SHA1 hex digest + legacy_hash = hashlib.sha1(password.encode('utf-8')).hexdigest() + + acc = models.Account() + acc.set_id("test_legacy_user") + # Manually inject the legacy hash into the data + acc.data['password'] = legacy_hash + acc.save(blocking=True) + + # Pull fresh copy + acc = models.Account.pull("test_legacy_user") + + # Check password - should return True and trigger upgrade + self.assertTrue(acc.check_password(password)) + + # Reload to verify persistence of the upgrade + acc = models.Account.pull("test_legacy_user") + current_hash = acc.data['password'] + + self.assertNotEqual(current_hash, legacy_hash) + self.assertFalse(models.Account._is_legacy_sha1_hash(current_hash)) + # Verify the new hash still works (via standard Werkzeug check inside check_password) + self.assertTrue(acc.check_password(password)) + + def test_legacy_password_upgrade_salted_sha1(self): + """ Test that a salted SHA1 hash (sha1$salt$hash) is verified and upgraded. """ + password = "password456" + salt = "somesalt" + # Create legacy salted hash: sha1(salt + password) + h = hashlib.sha1((salt + password).encode('utf-8')).hexdigest() + legacy_salted = f"sha1${salt}${h}" + + acc = models.Account() + acc.set_id("test_legacy_salted_user") + acc.data['password'] = legacy_salted + acc.save(blocking=True) + + # Pull fresh copy + acc = models.Account.pull("test_legacy_salted_user") + + # Check password + self.assertTrue(acc.check_password(password)) + + # Reload to verify persistence + acc = models.Account.pull("test_legacy_salted_user") + current_hash = acc.data['password'] + + self.assertNotEqual(current_hash, legacy_salted) + self.assertFalse(current_hash.startswith('sha1$')) + # Verify new hash works + self.assertTrue(acc.check_password(password)) \ No newline at end of file diff --git a/portality/models/account.py b/portality/models/account.py index bd733d98f2..526d8f9e24 100644 --- a/portality/models/account.py +++ b/portality/models/account.py @@ -1,4 +1,7 @@ import uuid +import hashlib +import hmac +import re from flask_login import UserMixin from datetime import timedelta from werkzeug.security import generate_password_hash, check_password_hash @@ -128,12 +131,95 @@ def clear_password(self): del self.data['password'] def check_password(self, password): + """Check the provided password against the stored hash. + + Handles legacy hashes removed in Werkzeug 3 (e.g. 'sha1$...' or raw 40-hex SHA1) by verifying once + and upgrading them to a modern hash. This preserves behaviour for existing records while moving + them forward to supported hash schemes. + """ try: - return check_password_hash(self.data['password'], password) + stored = self.data['password'] except KeyError: app.logger.error("Problem with user '{}' account: no password field".format(self.data['id'])) raise + # If the stored hash looks like a legacy SHA1 format, verify via compatibility shim first. + if self._is_legacy_sha1_hash(stored): + if self._verify_legacy_sha1(stored, password): + # Upgrade path: replace legacy hash with a modern one and persist. + # Note: This handles a breaking change in Werkzeug 3 (legacy verifiers removed). + self.set_password(password) + try: + # DomainObject.save() is expected to exist; failure to save should not block login success. + self.save() + except Exception as e: + app.logger.warning( + "Password upgraded for user '%s' but save failed: %s", self.data.get('id'), str(e) + ) + return True + return False + + # Otherwise, use Werkzeug's checker. If Werkzeug raises due to an unsupported legacy format, + # fall back to the legacy verifier as a last resort. + try: + return check_password_hash(stored, password) + except ValueError: + # Fallback for unsupported legacy formats encountered at runtime. + if self._verify_legacy_sha1(stored, password): + self.set_password(password) + try: + self.save() + except Exception as e: + app.logger.warning( + "Password upgraded for user '%s' after ValueError but save failed: %s", + self.data.get('id'), str(e) + ) + return True + return False + + # --- Legacy SHA1 compatibility (Werkzeug 3 removal) --- + _SHA1_HEX_RE = re.compile(r"^[a-f0-9]{40}$", re.IGNORECASE) + + @classmethod + def _is_legacy_sha1_hash(cls, stored: str) -> bool: + """Detect legacy SHA1 formats that Werkzeug 3 no longer supports. + + Supported legacy patterns: + - 'sha1$$' (old Werkzeug simple salted SHA1) + - '<40-hex>' (unsalted plain SHA1 of password) + """ + if not stored or not isinstance(stored, str): + return False + if stored.startswith('sha1$'): + parts = stored.split('$') + return len(parts) == 3 and bool(parts[1]) and bool(parts[2]) + # plain 40 hex characters implies unsalted SHA1 + return bool(cls._SHA1_HEX_RE.fullmatch(stored)) + + @classmethod + def _verify_legacy_sha1(cls, stored: str, password: str) -> bool: + """Verify a password against legacy SHA1 formats. + + - 'sha1$$' uses sha1(salt + password) + - '<40-hex>' uses sha1(password) + """ + if not stored or password is None: + return False + try: + if stored.startswith('sha1$'): + # salted format: sha1$$ + _, salt, hexdigest = stored.split('$', 2) + digest = hashlib.sha1((salt + password).encode('utf-8')).hexdigest() + return hmac.compare_digest(digest, hexdigest) + # unsalted plain SHA1 hex + if cls._SHA1_HEX_RE.fullmatch(stored): + digest = hashlib.sha1(password.encode('utf-8')).hexdigest() + return hmac.compare_digest(digest, stored.lower()) + except Exception: + # Any parsing/encoding issues -> treat as non-match + return False + return False + @property def journal(self): return self.data.get("journal") diff --git a/portality/scripts/ingestarticles.py b/portality/scripts/ingestarticles.py index bd92521141..ba303a89b2 100644 --- a/portality/scripts/ingestarticles.py +++ b/portality/scripts/ingestarticles.py @@ -2,7 +2,7 @@ from portality.tasks.ingestarticles import IngestArticlesBackgroundTask from portality.background import BackgroundApi from portality.models.background import StdOutBackgroundJob -from werkzeug import FileStorage +from werkzeug.datastructures import FileStorage if __name__ == "__main__": if app.config.get("SCRIPTS_READ_ONLY_MODE", False): diff --git a/setup.py b/setup.py index 89a9fcc73e..8c1725fb54 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,8 @@ "feedparser==6.0.11", "jinja2~=3.1.4", "jsonpath-ng~=1.6", - "flask<3", - "Werkzeug<3.0", # FIXME: we have passwords using plain sha1 that are undecodable after 3.0 + "flask==3.1.2", + "Werkzeug~=3.1", "Flask-Cors==5.0.0", "Flask-DebugToolbar==0.15.1", "Flask-Login==0.6.3",