# -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # 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