Files
GarminHealthLab/backend/auth.py
ericwyuan a71c5438ce 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>
2026-08-23 12:34:18 +08:00

118 lines
3.5 KiB
Python

"""
Authentication helpers: password hashing, JWT signing/verification,
and the require_auth decorator used by route blueprints.
Hashes are stored in a self-describing format so the algorithm can be
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 hmac
import os
import datetime
import jwt
from flask import request, g, jsonify
from functools import wraps
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):
def __init__(self, code, message):
super().__init__(message)
self.code = code
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:
salt = os.urandom(_SALT_BYTES)
derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS)
return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}"
def _verify_legacy_scrypt(password: str, stored: str) -> bool:
"""Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported."""
if not hasattr(hashlib, "scrypt"):
return False
salt_hex, hash_hex = stored.split(":", 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
derived = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=_DK_LEN
).hex()
return hmac.compare_digest(derived, hash_hex)
def verify_password(password: str, stored: str) -> bool:
if not stored:
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:
now = datetime.datetime.utcnow()
payload = {
"sub": user_id,
"iat": now,
"exp": now + datetime.timedelta(days=JWT_EXPIRY_DAYS),
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
def verify_token(token: str) -> dict:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return {"user_id": payload["sub"]}
def require_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "missing or malformed Authorization header"}), 401
token = auth[7:].strip()
try:
data = verify_token(token)
except jwt.ExpiredSignatureError:
return jsonify({"error": "token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401
g.user_id = data["user_id"]
return f(*args, **kwargs)
return wrapper