feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站 - 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号 同一事务,并发提交只有一个能成功 - 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据 中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取 - 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为 「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」; 随之清理陈旧 CSS 数据中枢 - 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验 主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站 - 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取 /models,失败退回卡内手动录入)、会员管理与邀请码页 - 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量 自测 - 主站 verify_baseline 通过(498 项);数据中枢 235 项通过 - tools/verify_datahub_console.py 端到端跑通两服务真实对话; tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
+10
-124
@@ -1,39 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from datahub.crypto import SecretVault, mask_secret
|
||||
from datahub.db import HubDB
|
||||
from datahub.timeutil import isoformat, now_shanghai
|
||||
from datahub.timeutil import isoformat
|
||||
|
||||
PBKDF2_ROUNDS = 200_000
|
||||
SESSION_HOURS = 12
|
||||
LOGIN_FAIL_LIMIT = 5
|
||||
LOCK_MINUTES = 10
|
||||
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
raw_salt = salt or os.urandom(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), raw_salt, PBKDF2_ROUNDS, dklen=32)
|
||||
return (
|
||||
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
|
||||
base64.urlsafe_b64encode(digest).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
|
||||
try:
|
||||
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
|
||||
_, actual = hash_password(password, salt)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected_hash)
|
||||
|
||||
|
||||
def token_hash(token: str) -> str:
|
||||
@@ -41,12 +14,18 @@ def token_hash(token: str) -> str:
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: HubDB, vault: SecretVault, api_token: str, admin_password: str) -> None:
|
||||
"""Machine credentials only: the `/v1` API token and the provider secrets.
|
||||
|
||||
Operator accounts live on the review site (HEL-560) — the console verifies
|
||||
them through `SiteAuth`, so nothing here authenticates a person.
|
||||
"""
|
||||
|
||||
def __init__(self, db: HubDB, vault: SecretVault, api_token: str) -> None:
|
||||
self.db = db
|
||||
self.vault = vault
|
||||
self._bootstrap(api_token, admin_password)
|
||||
self._bootstrap(api_token)
|
||||
|
||||
def _bootstrap(self, api_token: str, admin_password: str) -> None:
|
||||
def _bootstrap(self, api_token: str) -> None:
|
||||
if api_token:
|
||||
existing = self.db.fetchone("SELECT token_hash FROM api_tokens WHERE name = ?", ("review",))
|
||||
hashed = token_hash(api_token)
|
||||
@@ -61,17 +40,6 @@ class AuthService:
|
||||
"UPDATE api_tokens SET token_hash = ?, last4 = ? WHERE name = ?",
|
||||
(hashed, last4, "review"),
|
||||
)
|
||||
admin = self.db.fetchone("SELECT id FROM hub_admin WHERE username = ?", ("hub_admin",))
|
||||
if admin is None and admin_password:
|
||||
salt, hashed = hash_password(admin_password)
|
||||
now = isoformat()
|
||||
self.db.execute(
|
||||
"""
|
||||
INSERT INTO hub_admin(username, password_salt, password_hash, password_must_change, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)
|
||||
""",
|
||||
("hub_admin", salt, hashed, now, now),
|
||||
)
|
||||
|
||||
def check_api_token(self, supplied: str) -> bool:
|
||||
if not supplied:
|
||||
@@ -82,88 +50,6 @@ class AuthService:
|
||||
)
|
||||
return row is not None
|
||||
|
||||
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", (username,))
|
||||
if not user:
|
||||
raise PermissionError("账号或密码错误")
|
||||
now = now_shanghai()
|
||||
locked_until = user.get("locked_until")
|
||||
if locked_until:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
if datetime.fromisoformat(str(locked_until)) > now:
|
||||
raise PermissionError("账号已锁定,请稍后再试")
|
||||
except ValueError:
|
||||
pass
|
||||
if not verify_password(password, str(user["password_salt"]), str(user["password_hash"])):
|
||||
fails = int(user["failed_attempts"] or 0) + 1
|
||||
lock = isoformat(now + timedelta(minutes=LOCK_MINUTES)) if fails >= LOGIN_FAIL_LIMIT else None
|
||||
self.db.execute(
|
||||
"UPDATE hub_admin SET failed_attempts = ?, locked_until = ? WHERE id = ?",
|
||||
(fails, lock, user["id"]),
|
||||
)
|
||||
raise PermissionError("账号或密码错误")
|
||||
self.db.execute(
|
||||
"UPDATE hub_admin SET failed_attempts = 0, locked_until = NULL WHERE id = ?",
|
||||
(user["id"],),
|
||||
)
|
||||
session = secrets.token_urlsafe(32)
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
expires = isoformat(now + timedelta(hours=SESSION_HOURS))
|
||||
self.db.execute(
|
||||
"INSERT INTO hub_sessions(token_hash, csrf_token, expires_at, created_at) VALUES (?,?,?,?)",
|
||||
(token_hash(session), csrf, expires, isoformat(now)),
|
||||
)
|
||||
return {
|
||||
"session": session,
|
||||
"csrf": csrf,
|
||||
"must_change": bool(user["password_must_change"]),
|
||||
"expires_at": expires,
|
||||
}
|
||||
|
||||
def session_user(self, raw_token: str) -> dict[str, Any] | None:
|
||||
if not raw_token:
|
||||
return None
|
||||
row = self.db.fetchone(
|
||||
"SELECT * FROM hub_sessions WHERE token_hash = ?",
|
||||
(token_hash(raw_token),),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
if str(row["expires_at"]) < isoformat():
|
||||
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (row["token_hash"],))
|
||||
return None
|
||||
admin = self.db.fetchone("SELECT username, password_must_change FROM hub_admin WHERE username = ?", ("hub_admin",))
|
||||
return {
|
||||
"username": (admin or {}).get("username") or "hub_admin",
|
||||
"csrf_token": row["csrf_token"],
|
||||
"must_change": bool((admin or {}).get("password_must_change")),
|
||||
"token_hash": row["token_hash"],
|
||||
}
|
||||
|
||||
def logout(self, raw_token: str) -> None:
|
||||
if raw_token:
|
||||
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (token_hash(raw_token),))
|
||||
|
||||
def change_password(self, current: str, new_password: str) -> None:
|
||||
if len(new_password) < 8:
|
||||
raise ValueError("新密码至少 8 位")
|
||||
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
|
||||
if not user or not verify_password(current, str(user["password_salt"]), str(user["password_hash"])):
|
||||
raise PermissionError("当前密码错误")
|
||||
salt, hashed = hash_password(new_password)
|
||||
self.db.execute(
|
||||
"UPDATE hub_admin SET password_salt=?, password_hash=?, password_must_change=0, updated_at=? WHERE id=?",
|
||||
(salt, hashed, isoformat(), user["id"]),
|
||||
)
|
||||
|
||||
def confirm_password(self, password: str) -> bool:
|
||||
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
|
||||
if not user:
|
||||
return False
|
||||
return verify_password(password, str(user["password_salt"]), str(user["password_hash"]))
|
||||
|
||||
def credential_status(self, name: str) -> dict[str, Any]:
|
||||
row = self.db.fetchone("SELECT last4, updated_at FROM credentials WHERE name = ?", (name,))
|
||||
if not row:
|
||||
|
||||
Reference in New Issue
Block a user