fix(auth): hashlib.scrypt 在 LibreSSL 构建下缺失导致注册返回 500

macOS 自带 Python 3.9 链接 LibreSSL,hashlib 不提供 scrypt,
register 调用 hash_password 时抛 AttributeError,被 Flask 兜成
500 internal error(登录随之全部失败)。

改用 PBKDF2-HMAC-SHA256(每个 CPython 构建都有),并采用自描述的
存储格式 pbkdf2_sha256$iterations$salt$hash,便于日后迁移算法。
verify_password 仍兼容旧的 salt:hash scrypt 格式(当解释器支持时)。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 12:34:18 +08:00
parent 0177758e1f
commit a71c5438ce

View File

@@ -1,9 +1,17 @@
""" """
Authentication helpers: scrypt password hashing, JWT signing/verification, Authentication helpers: password hashing, JWT signing/verification,
and the require_auth decorator used by route blueprints. and the require_auth decorator used by route blueprints.
Password hashing mirrors the original Node implementation exactly: Hashes are stored in a self-describing format so the algorithm can be
salt (16 random bytes, hex) : scrypt(password, salt, n=16384, r=8, p=1, dklen=64, hex) migrated later without invalidating existing rows:
pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>
PBKDF2-HMAC-SHA256 is used because it is available in every CPython build.
`hashlib.scrypt` is NOT: builds linked against LibreSSL (notably the system
Python on macOS) omit it, which made registration fail with a 500.
Legacy `<salt_hex>:<hash_hex>` scrypt hashes are still verified when the
running interpreter supports scrypt.
""" """
import hashlib import hashlib
import hmac import hmac
@@ -16,6 +24,11 @@ from functools import wraps
from config import JWT_SECRET, JWT_EXPIRY_DAYS from config import JWT_SECRET, JWT_EXPIRY_DAYS
PBKDF2_ITERATIONS = 200_000
PBKDF2_PREFIX = "pbkdf2_sha256"
_SALT_BYTES = 16
_DK_LEN = 64
class AuthError(Exception): class AuthError(Exception):
def __init__(self, code, message): def __init__(self, code, message):
@@ -24,21 +37,21 @@ class AuthError(Exception):
self.message = message self.message = message
def _pbkdf2(password: str, salt: bytes, iterations: int) -> str:
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations, dklen=_DK_LEN
).hex()
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
salt = os.urandom(16) salt = os.urandom(_SALT_BYTES)
derived = hashlib.scrypt( derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS)
password.encode("utf-8"), return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}"
salt=salt,
n=16384,
r=8,
p=1,
dklen=64,
)
return f"{salt.hex()}:{derived.hex()}"
def verify_password(password: str, stored: str) -> bool: def _verify_legacy_scrypt(password: str, stored: str) -> bool:
if not stored or ":" not in stored: """Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported."""
if not hasattr(hashlib, "scrypt"):
return False return False
salt_hex, hash_hex = stored.split(":", 1) salt_hex, hash_hex = stored.split(":", 1)
try: try:
@@ -46,14 +59,28 @@ def verify_password(password: str, stored: str) -> bool:
except ValueError: except ValueError:
return False return False
derived = hashlib.scrypt( derived = hashlib.scrypt(
password.encode("utf-8"), password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=_DK_LEN
salt=salt, ).hex()
n=16384, return hmac.compare_digest(derived, hash_hex)
r=8,
p=1,
dklen=64, def verify_password(password: str, stored: str) -> bool:
) if not stored:
return hmac.compare_digest(derived.hex(), hash_hex) return False
if stored.startswith(PBKDF2_PREFIX + "$"):
try:
_, iterations, salt_hex, hash_hex = stored.split("$", 3)
salt = bytes.fromhex(salt_hex)
derived = _pbkdf2(password, salt, int(iterations))
except (ValueError, TypeError):
return False
return hmac.compare_digest(derived, hash_hex)
if ":" in stored:
return _verify_legacy_scrypt(password, stored)
return False
def sign_token(user_id: str) -> str: def sign_token(user_id: str) -> str: