191 lines
6.5 KiB
Python
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)
|