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 "")