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>
118 lines
3.5 KiB
Python
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
|