Files
PlantAI-rag-new/sdk/app/services/auth.py
T

191 lines
6.5 KiB
Python

"""services/auth.py —— 管理员鉴权:密码哈希、会话凭据、用户 CRUD、种子管理员。
密码用 stdlib pbkdf2_hmac + 每用户随机盐(不新增依赖);
会话凭据用不透明 token 存 auth_tokens,逐次查库(吊销/过期即时生效)。
所有 DB 访问经 `db` 参数注入(与 services/history.py 一致,避免依赖全局单例)。
"""
from __future__ import annotations
import hashlib
import logging
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from ..config import get_settings
from ..db import Database, dumps_json, loads_json
from ..logwriter import log_event
logger = logging.getLogger(__name__)
# 授权清单功能单元(9 项,与管理后台界面一致;后续可调)
AUTHORIZATION_UNITS = (
"qa", "kg", "extraction", "docgen", "figures",
"private_docs", "bioflow", "paper", "legacy",
)
_PBKDF2_ITERATIONS = 200_000
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
salt = salt or secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS
)
return digest.hex(), salt
def verify_password(password: str, salt: str, expected_hex: str) -> bool:
digest, _ = hash_password(password, salt)
return secrets.compare_digest(digest, expected_hex)
def _row_to_user(row: Any) -> dict[str, Any]:
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"authorizations": loads_json(row["authorizations"], default=[]),
"created_at": row["created_at"],
}
# ---- 登录校验 ----
async def verify_login(db: Database, username: str, password: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT id, username, password_hash, salt, role, authorizations, created_at "
"FROM users WHERE username = ?",
(username,),
)
if row is None:
return None
if not verify_password(password, row["salt"], row["password_hash"]):
return None
return _row_to_user(row)
# ---- 会话凭据 ----
async def issue_token(db: Database, user_id: str, ttl_seconds: int | None = None) -> str:
settings = get_settings()
ttl = ttl_seconds if ttl_seconds is not None else settings.admin_token_ttl_seconds
token = secrets.token_urlsafe(32)
now = _now_iso()
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl)
await db.execute(
"INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) "
"VALUES (?, ?, ?, ?, 0)",
(token, user_id, expires.strftime("%Y-%m-%dT%H:%M:%SZ"), now),
)
return token
async def resolve_token(db: Database, token: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT t.expires_at, t.revoked, u.id, u.username, u.role, u.authorizations, u.created_at "
"FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?",
(token,),
)
if row is None or row["revoked"]:
return None
try:
expires = datetime.strptime(row["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError:
return None
if datetime.now(timezone.utc) >= expires:
return None
return _row_to_user(row)
async def revoke_token(db: Database, token: str) -> None:
await db.execute("UPDATE auth_tokens SET revoked = 1 WHERE token = ?", (token,))
# ---- 用户 CRUD ----
async def list_users(db: Database) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT id, username, role, authorizations, created_at FROM users ORDER BY created_at ASC"
)
return [_row_to_user(r) for r in rows]
async def get_user(db: Database, user_id: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT id, username, role, authorizations, created_at FROM users WHERE id = ?", (user_id,)
)
return _row_to_user(row) if row else None
async def get_user_by_username(db: Database, username: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT id, username, role, authorizations, created_at FROM users WHERE username = ?", (username,)
)
return _row_to_user(row) if row else None
async def create_user(
db: Database, username: str, password: str, role: str, authorizations: list[str]
) -> dict[str, Any]:
user_id = str(uuid.uuid4())
ph, salt = hash_password(password)
await db.execute(
"INSERT INTO users (id, username, password_hash, salt, role, authorizations, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(user_id, username, ph, salt, role, dumps_json(authorizations), _now_iso()),
)
log_event("user_created", detail={"target": username, "role": role})
return {"id": user_id, "username": username, "role": role, "authorizations": authorizations, "created_at": _now_iso()}
async def update_user(
db: Database,
user_id: str,
*,
role: str | None = None,
authorizations: list[str] | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
fields: list[str] = []
params: list[Any] = []
if role is not None:
fields.append("role = ?")
params.append(role)
if authorizations is not None:
fields.append("authorizations = ?")
params.append(dumps_json(authorizations))
if password is not None:
ph, salt = hash_password(password)
fields.append("password_hash = ?")
params.append(ph)
fields.append("salt = ?")
params.append(salt)
if fields:
params.append(user_id)
await db.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", params)
log_event("user_updated", detail={"target_id": user_id, "role": role, "password_reset": password is not None})
return await get_user(db, user_id)
async def delete_user(db: Database, user_id: str) -> None:
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
log_event("user_deleted", detail={"target_id": user_id})
# ---- 种子管理员 ----
async def seed_admin(db: Database) -> None:
"""启动时确保存在至少一个 admin 用户(默认 admin/admin)。"""
row = await db.fetchone("SELECT id FROM users WHERE role = 'admin' LIMIT 1")
if row is not None:
return
settings = get_settings()
await create_user(db, settings.admin_seed_username, settings.admin_seed_password, "admin", list(AUTHORIZATION_UNITS))
logger.info("已创建种子管理员 %s", settings.admin_seed_username)