feat(HEL-382): 搭建 datahub 底座和盘后正式数据链路
新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
c2ebc0ab91
commit
3498dd7a4b
@@ -0,0 +1,190 @@
|
||||
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
|
||||
|
||||
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:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: HubDB, vault: SecretVault, api_token: str, admin_password: str) -> None:
|
||||
self.db = db
|
||||
self.vault = vault
|
||||
self._bootstrap(api_token, admin_password)
|
||||
|
||||
def _bootstrap(self, api_token: str, admin_password: str) -> None:
|
||||
if api_token:
|
||||
existing = self.db.fetchone("SELECT token_hash FROM api_tokens WHERE name = ?", ("review",))
|
||||
hashed = token_hash(api_token)
|
||||
last4 = mask_secret(api_token)
|
||||
if existing is None:
|
||||
self.db.execute(
|
||||
"INSERT INTO api_tokens(token_hash, name, last4, created_at) VALUES (?,?,?,?)",
|
||||
(hashed, "review", last4, isoformat()),
|
||||
)
|
||||
elif existing["token_hash"] != hashed:
|
||||
self.db.execute(
|
||||
"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:
|
||||
return False
|
||||
row = self.db.fetchone(
|
||||
"SELECT token_hash FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL",
|
||||
(token_hash(supplied),),
|
||||
)
|
||||
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:
|
||||
return {"configured": False, "last4": "", "updated_at": ""}
|
||||
return {"configured": True, "last4": row["last4"], "updated_at": row["updated_at"]}
|
||||
|
||||
def store_credential(self, name: str, secret: str) -> None:
|
||||
payload = self.vault.encrypt_json({name: secret})
|
||||
self.db.execute(
|
||||
"""
|
||||
INSERT INTO credentials(name, encrypted_payload, last4, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
encrypted_payload=excluded.encrypted_payload, last4=excluded.last4, updated_at=excluded.updated_at
|
||||
""",
|
||||
(name, payload, mask_secret(secret), isoformat()),
|
||||
)
|
||||
|
||||
def load_credential(self, name: str) -> str:
|
||||
row = self.db.fetchone("SELECT encrypted_payload FROM credentials WHERE name = ?", (name,))
|
||||
if not row:
|
||||
return ""
|
||||
data = self.vault.decrypt_json(str(row["encrypted_payload"]))
|
||||
return str(data.get(name) or "")
|
||||
Reference in New Issue
Block a user