Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions doajtest/unit/test_Account_password_legacy.py
Original file line number Diff line number Diff line change
@@ -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))
88 changes: 87 additions & 1 deletion portality/models/account.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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$<salt>$<hexdigest>' (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$<salt>$<hexdigest>' 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>
_, 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")
Expand Down
2 changes: 1 addition & 1 deletion portality/scripts/ingestarticles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,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",
Expand Down