feat: 平台认证(scrypt上移/内存token 8h/登录限流/改密/角色依赖/409语义)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+9
-60
@@ -17,19 +17,16 @@ PlatformBase.metadata,engine/models.ensure_schema 只跑 Base.metadata——
|
||||
- admin_users:tenant_id NULL = 超管。password_hash 存版本化串
|
||||
"scrypt$<n>$<r>$<p>$<salt_hex>$<hash_hex>"(参数与摘要同存,将来提参/
|
||||
换算法有迁移路径,verify 解析兼容);salt 列同步存同一盐 hex(单一写方
|
||||
hash_password,verify 以串内为准)。哈希 = hashlib.scrypt(n=2**14, r=8,
|
||||
p=1)——偏差注记:spec §7.2 写 bcrypt/argon2,用 stdlib scrypt 为有意
|
||||
取舍(零新依赖,抗 GPU 性能同级);verify 走 hmac.compare_digest
|
||||
常量时间比较。
|
||||
hash_password,verify 以串内为准)。哈希实现 = hashlib.scrypt(n=2**14,
|
||||
r=8, p=1)——Task 2 起上移至 platform/auth.py(单一事实源,签名纯参式),
|
||||
本模块 re-export 兼容既有导入;偏差注记:spec §7.2 写 bcrypt/argon2,
|
||||
用 stdlib scrypt 为有意取舍(零新依赖,抗 GPU 性能同级)。
|
||||
|
||||
测试/teardown 纪律:建过 platform engine 的测试结尾 await engine.dispose()
|
||||
(Windows 文件锁,同 registry)。
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import pathlib
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
@@ -51,6 +48,10 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
# 密码哈希单一定义点:Task 2 上移至 platform/auth.py(纯参式签名,解 ORM
|
||||
# 绑定——M-2b),此处 re-export 兼容既有 from app.platform.db import 路径
|
||||
from app.platform.auth import hash_password, verify_password # noqa: F401
|
||||
|
||||
|
||||
def _utcnow() -> datetime.datetime:
|
||||
# 跟随租户库 models.py 既有约定:datetime.now(timezone.utc)(tz-aware UTC)
|
||||
@@ -173,59 +174,7 @@ async def ensure_platform_schema(engine: AsyncEngine) -> None:
|
||||
await conn.run_sync(PlatformBase.metadata.create_all)
|
||||
|
||||
|
||||
# ---- 密码哈希(scrypt,偏差注记见模块 docstring)---------------------------
|
||||
|
||||
_SCRYPT_N = 2 ** 14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
"""scrypt(password, salt) → (password_hash, salt)。
|
||||
|
||||
password_hash 为版本化串 "scrypt$<n>$<r>$<p>$<salt_hex>$<hash_hex>":
|
||||
参数与摘要同存,将来提参/换算法时旧行自解释,有迁移路径(M-1)。
|
||||
salt 返回值(列存)与串内嵌的同一盐 hex——单一写方在此同步两处,
|
||||
verify 以串内为准。salt 缺省每次随机 16 字节;显式传 salt 可复现
|
||||
(测试/verify)。"""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt,
|
||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P)
|
||||
versioned = (
|
||||
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}"
|
||||
f"${salt.hex()}${digest.hex()}")
|
||||
return versioned, salt.hex()
|
||||
|
||||
|
||||
def verify_password(password: str, user: AdminUser) -> bool:
|
||||
"""解析 password_hash 版本化串重算比对,常量时间比较
|
||||
(hmac.compare_digest)——防时序侧信道。
|
||||
|
||||
解析兼容(M-1):带 "scrypt$" 前缀 → 参数/盐/摘要全取自串内;
|
||||
无前缀(过渡/手工行)→ 盐取 salt 列、参数取当前默认。任何解析失败
|
||||
(坏 hex/坏段数/坏整数)按验败返回 False 而非炸——存储异常不该变 500。"""
|
||||
stored = user.password_hash
|
||||
try:
|
||||
if stored.startswith("scrypt$"):
|
||||
parts = stored.split("$")
|
||||
if len(parts) != 6:
|
||||
return False
|
||||
_, n_s, r_s, p_s, salt_hex, hash_hex = parts
|
||||
n, r, p = int(n_s), int(r_s), int(p_s)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(hash_hex)
|
||||
else:
|
||||
salt = bytes.fromhex(user.salt)
|
||||
expected = bytes.fromhex(stored)
|
||||
n, r, p = _SCRYPT_N, _SCRYPT_R, _SCRYPT_P
|
||||
except ValueError:
|
||||
return False
|
||||
actual = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt, n=n, r=r, p=p)
|
||||
return hmac.compare_digest(expected, actual)
|
||||
|
||||
# ---- 密码哈希:定义在 auth.py(见文件头 re-export)---------------------------
|
||||
|
||||
# ---- 超管引导 ---------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user