Files
butubb 4e60524f37
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat: 监控面板 / 登录鉴权 / 多平台切换 / MySQL
在上游 MediaCrawler 之上新增一层:

- 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、
  企业微信通知。每轮采集写入独立目录,差分才成立。
- WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。
  WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。
- 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」,
  未接通的平台直接拒绝建任务,而不是静默跑空。
- 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4
  (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库,
  连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。

修复上游缺陷:

- xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到,
  而那份资料只喂给一个空函数。改为尽力而为,失败不中断。
- xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。
  新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。
- requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、
  这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。

改动过的上游文件清单及合并方式见 UPSTREAM.md。

测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关)
2026-10-07 09:58:40 +08:00

504 lines
20 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_auth.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for WebUI authentication.
Deliberately does NOT install ``app.dependency_overrides``: the point of this
file is to exercise the real enforcement path. Other suites override
``require_auth`` so they can keep testing their own concerns.
"""
import asyncio
import time
import httpx
import pytest
import pytest_asyncio
from fastapi import WebSocketException
from sqlalchemy import func, select
from api import auth
from api.main import app
from api.monitor import db as monitor_db
from api.monitor.models import AuthSession
PASSWORD = "correct-horse-battery"
# Captured at import, i.e. before the autouse fixture patches the module global,
# so the guard test below checks the value that actually ships.
REAL_PBKDF2_ITERATIONS = auth.PBKDF2_ITERATIONS
# Every /api route that is allowed to answer without a session.
EXEMPT_PATHS = {"/api/health", "/api/auth/login", "/api/auth/logout"}
@pytest.fixture(autouse=True)
def cheap_hashing(monkeypatch):
"""600k iterations is right in production and unusable in a test suite.
hash_password() resolves the count at call time precisely so this works.
"""
monkeypatch.setattr(auth, "PBKDF2_ITERATIONS", 1_000)
monkeypatch.delenv("MC_PASSWORD", raising=False)
auth.reset_throttle_state()
yield
auth.reset_throttle_state()
@pytest_asyncio.fixture
async def db(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
yield monitor_db
await monitor_db.dispose_engine()
@pytest_asyncio.fixture
async def client(db):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed_password(password: str = PASSWORD) -> None:
async with monitor_db.get_session() as session:
await auth.set_password(session, password)
# --------------------------------------------------------------------------
# Password hashing
# --------------------------------------------------------------------------
class TestPasswordHashing:
def test_iteration_count_has_not_been_lowered(self):
"""Guard: someone trimming this for speed would weaken every install."""
assert REAL_PBKDF2_ITERATIONS >= 600_000
def test_round_trip(self):
stored = auth.hash_password(PASSWORD)
assert auth._verify_password_sync(PASSWORD, stored) is True
def test_wrong_password_rejected(self):
stored = auth.hash_password(PASSWORD)
assert auth._verify_password_sync("wrong", stored) is False
def test_same_password_hashes_differently(self):
"""A fixed salt would let one rainbow table crack every install."""
assert auth.hash_password(PASSWORD) != auth.hash_password(PASSWORD)
def test_format_is_self_describing(self):
algo, iterations, salt, digest = auth.hash_password(PASSWORD).split("$")
assert algo == "pbkdf2_sha256"
assert int(iterations) == auth.PBKDF2_ITERATIONS
assert salt and digest
@pytest.mark.parametrize("stored", ["", "garbage", "md5$1$a$b", "pbkdf2_sha256$x$a$b"])
def test_malformed_stored_hash_is_rejected_not_raised(self, stored):
assert auth._verify_password_sync(PASSWORD, stored) is False
# --------------------------------------------------------------------------
# Credentials
# --------------------------------------------------------------------------
class TestCredentials:
@pytest.mark.asyncio
async def test_check_password_against_stored_hash(self, db):
await _seed_password()
async with monitor_db.get_session() as session:
assert await auth.check_password(session, PASSWORD) is True
assert await auth.check_password(session, "nope") is False
@pytest.mark.asyncio
async def test_no_password_configured_denies_everything(self, db):
"""An unset credential must not mean "open"."""
async with monitor_db.get_session() as session:
assert await auth.check_password(session, "") is False
assert await auth.check_password(session, PASSWORD) is False
@pytest.mark.asyncio
async def test_env_override_wins_and_is_not_persisted(self, db, monkeypatch):
"""The documented way back in after forgetting the password."""
await _seed_password("stored-password")
monkeypatch.setenv("MC_PASSWORD", "env-password")
async with monitor_db.get_session() as session:
assert await auth.check_password(session, "env-password") is True
assert await auth.check_password(session, "stored-password") is False
# Override must never be written to disk.
assert await auth.current_password_hash(session) != ""
assert "env-password" not in (await auth.current_password_hash(session))
@pytest.mark.asyncio
async def test_first_run_generates_a_credential(self, db, monkeypatch):
monkeypatch.delenv("MC_PASSWORD", raising=False)
generated = await auth.ensure_initial_credential()
assert generated
# Second call is a no-op.
assert await auth.ensure_initial_credential() is None
async with monitor_db.get_session() as session:
assert await auth.check_password(session, generated) is True
@pytest.mark.asyncio
async def test_first_run_defers_to_env_password(self, db, monkeypatch):
monkeypatch.setenv("MC_PASSWORD", "env-password")
assert await auth.ensure_initial_credential() is None
async with monitor_db.get_session() as session:
assert await auth.current_password_hash(session) == ""
# --------------------------------------------------------------------------
# Sessions
# --------------------------------------------------------------------------
class TestSessions:
@pytest.mark.asyncio
async def test_round_trip(self, db):
async with monitor_db.get_session() as session:
token, expires_at = await auth.create_session(session)
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, token) is not None
assert expires_at > 0
@pytest.mark.asyncio
async def test_only_the_hash_is_stored(self, db):
"""A database leak must not hand over live sessions."""
async with monitor_db.get_session() as session:
token, _ = await auth.create_session(session)
async with monitor_db.get_session() as session:
stored = (await session.scalars(select(AuthSession.token_hash))).all()
assert token not in stored
assert auth._hash_token(token) in stored
@pytest.mark.asyncio
async def test_expired_session_is_rejected_and_removed(self, db):
async with monitor_db.get_session() as session:
token, _ = await auth.create_session(session)
row = await session.get(AuthSession, auth._hash_token(token))
row.expires_at = 1 # long past
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, token) is None
# Fresh session: the identity map in the one above still holds the
# pending-delete object, so it would answer as if the row were present.
async with monitor_db.get_session() as session:
assert await session.get(AuthSession, auth._hash_token(token)) is None
@pytest.mark.asyncio
async def test_unknown_token_is_rejected(self, db):
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, "never-issued") is None
assert await auth.resolve_session(session, "") is None
@pytest.mark.asyncio
async def test_logout_revokes_only_that_session(self, db):
async with monitor_db.get_session() as session:
first, _ = await auth.create_session(session)
second, _ = await auth.create_session(session)
async with monitor_db.get_session() as session:
await auth.revoke_session(session, first)
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, first) is None
assert await auth.resolve_session(session, second) is not None
@pytest.mark.asyncio
async def test_revoke_all_clears_every_session(self, db):
async with monitor_db.get_session() as session:
await auth.create_session(session)
await auth.create_session(session)
async with monitor_db.get_session() as session:
removed = await auth.revoke_all_sessions(session)
assert removed == 2
async with monitor_db.get_session() as session:
assert await session.scalar(select(func.count()).select_from(AuthSession)) == 0
# --------------------------------------------------------------------------
# Throttle
# --------------------------------------------------------------------------
class TestThrottle:
@pytest.mark.asyncio
async def test_below_threshold_is_not_throttled(self, db):
key = "1.2.3.4"
for _ in range(auth.THROTTLE_THRESHOLD - 1):
await auth.record_failure(key)
assert await auth.retry_after_seconds(key) == 0
@pytest.mark.asyncio
async def test_lockout_after_repeated_failures(self, db):
key = "1.2.3.4"
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure(key)
assert await auth.retry_after_seconds(key) > 0
@pytest.mark.asyncio
async def test_success_clears_failures(self, db):
key = "1.2.3.4"
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure(key)
await auth.clear_failures(key)
assert await auth.retry_after_seconds(key) == 0
@pytest.mark.asyncio
async def test_failures_age_out_of_the_window(self, db, monkeypatch):
"""Driven by a fake clock rather than sleeping 15 minutes."""
key = "1.2.3.4"
clock = {"now": 1000.0}
monkeypatch.setattr(auth, "_now", lambda: clock["now"])
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure(key)
assert await auth.retry_after_seconds(key) > 0
clock["now"] += auth.THROTTLE_WINDOW_SECONDS + 1
assert await auth.retry_after_seconds(key) == 0
@pytest.mark.asyncio
async def test_keys_are_independent(self, db):
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure("attacker")
assert await auth.retry_after_seconds("attacker") > 0
assert await auth.retry_after_seconds("innocent") == 0
# --------------------------------------------------------------------------
# HTTP enforcement — the acceptance criteria
# --------------------------------------------------------------------------
class TestEnforcement:
@pytest.mark.asyncio
async def test_health_is_reachable_without_a_session(self, client):
assert (await client.get("/api/health")).status_code == 200
@pytest.mark.asyncio
async def test_protected_endpoint_returns_401_without_a_session(self, client):
response = await client.get("/api/monitor/tasks")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_wrong_password_is_401_and_generic(self, client):
await _seed_password()
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# Must not reveal whether a password is even configured.
assert response.json()["detail"] == auth.INVALID_CREDENTIALS
@pytest.mark.asyncio
async def test_login_unlocks_the_api(self, client):
await _seed_password()
login = await client.post("/api/auth/login", json={"password": PASSWORD})
assert login.status_code == 200
assert auth.SESSION_COOKIE_NAME in client.cookies
assert (await client.get("/api/monitor/tasks")).status_code == 200
@pytest.mark.asyncio
async def test_cookie_is_httponly_and_lax(self, client):
await _seed_password()
login = await client.post("/api/auth/login", json={"password": PASSWORD})
raw = login.headers["set-cookie"].lower()
assert "httponly" in raw
assert "samesite=lax" in raw
# Secure must be OFF by default: the LAN bind is plain HTTP and a Secure
# cookie is silently dropped there, looping the login page.
assert "secure" not in raw
@pytest.mark.asyncio
async def test_bearer_token_also_works(self, client):
"""Scripts and curl cannot use a cookie jar conveniently."""
await _seed_password()
login = await client.post("/api/auth/login", json={"password": PASSWORD})
token = login.cookies[auth.SESSION_COOKIE_NAME]
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as bare:
bare.headers["Authorization"] = f"Bearer {token}"
assert (await bare.get("/api/monitor/tasks")).status_code == 200
@pytest.mark.asyncio
async def test_tampered_token_is_rejected(self, client):
await _seed_password()
await client.post("/api/auth/login", json={"password": PASSWORD})
client.cookies.set(auth.SESSION_COOKIE_NAME, "not-a-real-token")
assert (await client.get("/api/monitor/tasks")).status_code == 401
@pytest.mark.asyncio
async def test_logout_invalidates_the_session(self, client):
await _seed_password()
await client.post("/api/auth/login", json={"password": PASSWORD})
assert (await client.get("/api/monitor/tasks")).status_code == 200
assert (await client.post("/api/auth/logout")).status_code == 200
assert (await client.get("/api/monitor/tasks")).status_code == 401
@pytest.mark.asyncio
async def test_me_reports_401_when_logged_out(self, client):
await _seed_password()
assert (await client.get("/api/auth/me")).status_code == 401
await client.post("/api/auth/login", json={"password": PASSWORD})
me = await client.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["authenticated"] is True
@pytest.mark.asyncio
async def test_password_change_evicts_other_devices(self, client):
await _seed_password()
# A second "device" holds its own session.
login = await client.post("/api/auth/login", json={"password": PASSWORD})
other_token = login.cookies[auth.SESSION_COOKIE_NAME]
changed = await client.post(
"/api/auth/password",
json={"current": PASSWORD, "new": "brand-new-password"},
)
assert changed.status_code == 200
# The old token is dead.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as other:
other.cookies.set(auth.SESSION_COOKIE_NAME, other_token)
assert (await other.get("/api/monitor/tasks")).status_code == 401
# ...and the caller is still logged in.
assert (await client.get("/api/monitor/tasks")).status_code == 200
@pytest.mark.asyncio
async def test_password_change_requires_the_current_password(self, client):
await _seed_password()
await client.post("/api/auth/login", json={"password": PASSWORD})
response = await client.post(
"/api/auth/password", json={"current": "wrong", "new": "whatever-new"}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_repeated_failures_get_throttled(self, client):
await _seed_password()
for _ in range(auth.THROTTLE_THRESHOLD):
await client.post("/api/auth/login", json={"password": "wrong"})
blocked = await client.post("/api/auth/login", json={"password": PASSWORD})
assert blocked.status_code == 429
assert "retry-after" in {k.lower() for k in blocked.headers}
@pytest.mark.asyncio
async def test_docs_are_not_exposed(self, client):
for path in ("/docs", "/redoc", "/openapi.json"):
assert (await client.get(path)).status_code == 404
class TestEveryRouteIsGuarded:
@pytest.mark.asyncio
async def test_no_api_route_is_accidentally_open(self, client):
"""The guard that stops the next endpoint from shipping unauthenticated."""
unguarded = []
for route in app.routes:
path = getattr(route, "path", "")
methods = getattr(route, "methods", None)
if not path.startswith("/api") or not methods or path in EXEMPT_PATHS:
continue
# Substitute dummy values for path params so we reach the auth check
# rather than a 404/422 on the parameter itself.
concrete = "/".join(
"1" if segment.startswith("{") else segment for segment in path.split("/")
)
for method in methods - {"HEAD", "OPTIONS"}:
response = await client.request(method, concrete, json={})
if response.status_code != 401:
unguarded.append(f"{method} {path} -> {response.status_code}")
assert not unguarded, f"以下 /api 路由未受鉴权保护:{unguarded}"
# --------------------------------------------------------------------------
# WebSocket enforcement
# --------------------------------------------------------------------------
class _FakeWebSocket:
"""Only `.cookies` is read by require_ws_auth."""
def __init__(self, cookies):
self.cookies = cookies
class TestWebSocketAuth:
"""Guarding websockets needs its own mechanism: BaseHTTPMiddleware returns
early for non-http scopes, and HTTP router dependencies never run for them.
Without this the live crawl log stream would be wide open.
"""
@pytest.mark.asyncio
async def test_missing_cookie_is_rejected(self, db):
with pytest.raises(WebSocketException) as excinfo:
await auth.require_ws_auth(_FakeWebSocket({}))
assert excinfo.value.code == 1008
@pytest.mark.asyncio
async def test_valid_cookie_is_accepted(self, db):
async with monitor_db.get_session() as session:
token, _ = await auth.create_session(session)
# No exception means accepted.
await auth.require_ws_auth(_FakeWebSocket({auth.SESSION_COOKIE_NAME: token}))
@pytest.mark.asyncio
async def test_unknown_cookie_is_rejected(self, db):
with pytest.raises(WebSocketException):
await auth.require_ws_auth(_FakeWebSocket({auth.SESSION_COOKIE_NAME: "bogus"}))
def test_every_websocket_route_carries_the_guard(self):
guarded = {
route.path
for route in app.routes
if route.__class__.__name__ == "APIWebSocketRoute"
and any(
getattr(dep.dependency, "__name__", "") == "require_ws_auth"
for dep in (route.dependencies or [])
)
}
every_ws = {
route.path
for route in app.routes
if route.__class__.__name__ == "APIWebSocketRoute"
}
assert every_ws, "expected at least one websocket route"
assert every_ws == guarded