"""Authentication, authorization and CSRF boundaries over real PostgreSQL.""" import json from datetime import timedelta import pytest from pydantic import ValidationError from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.config import Settings from app.models.auth import AuthSession, User from app.models.catalog import utc_now from app.services.auth import COOKIE_NAME, check_password, token_hash PASSWORD = "test-password-123" ORIGIN = "http://localhost:5173" def register(client, username="test_user", **extra): return client.post( "/api/auth/register", json={"username": username, "password": PASSWORD, **extra} ) def login(client, username="test_user", password=PASSWORD): response = client.post("/api/auth/login", json={"username": username, "password": password}) if response.status_code == 200: client.headers["X-CSRF-Token"] = response.json()["csrf_token"] return response def test_registration_hashes_password_and_cannot_select_privilege( anonymous_client, migrated_database ): c = anonymous_client for extra in [{"role": "admin"}, {"id": 1}, {"password_hash": "injected"}]: assert register(c, **extra).status_code == 422 result = register(c, username=" Test_User ", email="user@example.com", nickname="占位用户") assert result.status_code == 201 assert result.json()["username"] == "test_user" and result.json()["role"] == "user" assert c.get("/api/auth/me").status_code == 401 assert register(c).status_code == 409 with Session(migrated_database) as db: user = db.scalar(select(User)) assert user.password_hash.startswith("$argon2id$") assert PASSWORD not in user.password_hash and check_password(user.password_hash, PASSWORD) assert db.scalar(select(func.count()).select_from(AuthSession)) == 0 assert "password" not in result.text and "credential_version" not in result.text @pytest.mark.parametrize( "data", [ {"password": "short"}, {"password": "x" * 129}, {"username": "a"}, {"username": "a b"}, {"email": "invalid"}, {"nickname": "x" * 81}, {"nickname": "bad\u0000name"}, ], ) def test_validation_does_not_echo_credentials(anonymous_client, data): submitted = {"username": "test_user", "password": PASSWORD, **data} response = anonymous_client.post("/api/auth/register", json=submitted) assert response.status_code == 422 assert '"input"' not in response.text assert json.dumps(submitted["password"]) not in response.text assert response.headers["cache-control"] == "no-store" @pytest.mark.parametrize("origin", [None, "null", "https://evil.example", ORIGIN + ".evil.example"]) def test_login_and_registration_reject_untrusted_origin( anonymous_client, migrated_database, origin ): c = anonymous_client c.headers.pop("Origin", None) headers = {"Origin": origin} if origin else {} for path in ["register", "login"]: response = c.post( f"/api/auth/{path}", json={"username": "test_user", "password": PASSWORD}, headers=headers, ) assert response.status_code == 403 with Session(migrated_database) as db: assert db.scalar(select(func.count()).select_from(User)) == 0 def test_login_cookie_rotation_logout_and_opaque_storage(anonymous_client, migrated_database): c = anonymous_client register(c) response = login(c) assert response.status_code == 200 assert all( value in response.headers["set-cookie"] for value in ["HttpOnly", "Secure", "SameSite=lax", "Path=/"] ) first = c.cookies.get(COOKIE_NAME) csrf = response.json()["csrf_token"] assert c.get("/api/auth/me").json()["csrf_token"] == csrf with Session(migrated_database) as db: stored = db.scalar(select(AuthSession)) assert stored.token_hash == token_hash(first) and first not in stored.token_hash assert login(c).status_code == 200 second = c.cookies.get(COOKIE_NAME) assert first != second c.cookies.clear() c.cookies.set(COOKIE_NAME, first) assert c.get("/api/auth/me").status_code == 401 c.cookies.clear() c.cookies.set(COOKIE_NAME, second) response = c.post("/api/auth/logout") assert response.status_code == 204 and "Max-Age=0" in response.headers["set-cookie"] c.cookies.clear() c.cookies.set(COOKIE_NAME, second) assert c.get("/api/auth/me").status_code == 401 def test_login_failure_is_generic_and_throttled_in_database(anonymous_client): c = anonymous_client register(c) absent = login(c, "absent_user", "wrong").json() wrong = login(c, password="wrong") assert wrong.status_code == 401 and wrong.json() == absent for _ in range(9): assert login(c, password="wrong").status_code == 401 blocked = login(c) assert blocked.status_code == 429 and blocked.headers["retry-after"] == "300" @pytest.mark.parametrize("username", ["bad\u0000name", "invalid name", "../user"]) def test_login_rejects_invalid_username_before_database_lookup(anonymous_client, username): assert login(anonymous_client, username=username).status_code == 422 def test_expired_forged_and_credential_version_sessions_are_rejected( anonymous_client, migrated_database ): c = anonymous_client register(c) login(c) with Session(migrated_database) as db: session = db.scalar(select(AuthSession)) session.expires_at = utc_now() - timedelta(seconds=1) db.commit() assert c.get("/api/auth/me").status_code == 401 login(c) with Session(migrated_database) as db: db.scalar(select(User)).credential_version += 1 db.commit() assert c.get("/api/auth/me").status_code == 401 c.cookies.clear() c.cookies.set(COOKIE_NAME, "x" * 43) assert c.get("/api/auth/me").status_code == 401 c.cookies.clear() c.cookies.set(COOKIE_NAME, "invalid") assert c.get("/api/auth/me").status_code == 401 def test_csrf_is_bound_to_session_and_required_for_all_authenticated_writes(anonymous_client): c = anonymous_client register(c) login(c) first = c.headers["X-CSRF-Token"] login(c) for token in ["", "invalid", first]: assert ( c.patch( "/api/users/me", json={"nickname": "占位"}, headers={"X-CSRF-Token": token} ).status_code == 403 ) assert c.post("/api/auth/logout", headers={"X-CSRF-Token": token}).status_code == 403 assert ( c.patch( "/api/users/me", json={"nickname": "占位"}, headers={"Origin": "https://evil.example"} ).status_code == 403 ) c.headers.pop("Origin") assert c.patch("/api/users/me", json={"nickname": "占位"}).status_code == 403 c.headers["Origin"] = ORIGIN assert c.patch("/api/users/me", json={"nickname": " 占位 "}).json()["nickname"] == "占位" def test_profile_updates_only_own_allowed_fields(anonymous_client, migrated_database): c = anonymous_client first = register(c).json() register(c, "other_user") login(c) for payload in [ {"id": 2}, {"role": "admin"}, {"username": "other_user"}, {"password": "changed"}, {}, ]: assert c.patch("/api/users/me", json=payload).status_code == 422 assert ( c.patch("/api/users/me", json={"email": "changed@example.com", "nickname": "示例"}).json()[ "id" ] == first["id"] ) assert c.patch("/api/users/me", json={"email": "", "nickname": None}).json()["email"] is None with Session(migrated_database) as db: assert db.scalar(select(User).where(User.username == "other_user")).email is None assert db.scalar(select(User).where(User.id == first["id"])).role == "user" def test_password_change_revokes_all_sessions(anonymous_client): c = anonymous_client register(c) login(c) first = c.cookies.get(COOKIE_NAME) # Independent second browser: existing first session must also be invalidated. c.cookies.clear() login(c) assert ( c.post( "/api/auth/change-password", json={"current_password": "wrong", "new_password": "new-password-123"}, ).status_code == 400 ) response = c.post( "/api/auth/change-password", json={"current_password": PASSWORD, "new_password": "new-password-123"}, ) assert response.status_code == 204 assert c.get("/api/auth/me").status_code == 401 c.cookies.set(COOKIE_NAME, first) assert c.get("/api/auth/me").status_code == 401 assert login(c).status_code == 401 assert login(c, password="new-password-123").status_code == 200 def test_all_management_routes_require_admin_and_history_is_private(anonymous_client): c = anonymous_client paths = [ ("GET", "/api/admin/users"), ("GET", "/api/knowledge"), ("GET", "/api/knowledge/page"), ("GET", "/api/knowledge/1"), ("POST", "/api/knowledge"), ("PATCH", "/api/knowledge/1"), ("DELETE", "/api/knowledge/1?expected_version=1"), ("POST", "/api/knowledge/1/review"), ("GET", "/api/knowledge/1/history"), ("GET", "/api/sources"), ("POST", "/api/sources"), ("GET", "/api/sources/1"), ("PATCH", "/api/sources/1"), ("DELETE", "/api/sources/1?expected_version=1"), ("GET", "/api/sources/1/history"), ("GET", "/api/documents"), ("POST", "/api/documents"), ("GET", "/api/documents/1"), ("PATCH", "/api/documents/1"), ("DELETE", "/api/documents/1?expected_version=1"), ("GET", "/api/documents/1/history"), ("GET", "/api/retrieval/runs"), ("GET", "/api/retrieval/runs/00000000-0000-0000-0000-000000000000"), ] for method, path in paths: assert c.request(method, path, json={}).status_code == 401, path register(c) login(c) for method, path in paths: assert c.request(method, path, json={}).status_code == 403, path assert c.post("/api/retrieval", json={"query": "占位"}).json()["status"] == "no_evidence" response = c.post("/api/chat", json={"question": "占位"}) assert response.status_code == 200 assert response.json()["status"] == "no_evidence" def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database): c = client users = c.get("/api/admin/users").json() assert users[0]["role"] == "admin" and "password_hash" not in str(users) assert c.get("/api/admin/users?limit=101").status_code == 422 assert ( c.post( "/api/knowledge", json={"title": "示例", "content": "示例"}, headers={"X-CSRF-Token": ""}, ).status_code == 403 ) assert c.post("/api/knowledge", json={"title": "示例", "content": "示例"}).status_code == 201 with Session(migrated_database) as db: db.scalar(select(User)).role = "user" db.commit() assert c.get("/api/admin/users").status_code == 403 assert c.get("/api/knowledge").status_code == 403 def test_auth_settings_require_explicit_origins_and_secure_production(): with pytest.raises(ValidationError): Settings(_env_file=None, app_env="production", auth_cookie_secure=False) for origin in [ "*", "null", "https://example.com/path", "https://user@example.com", "https://*.example.com", ]: with pytest.raises(ValidationError): Settings(_env_file=None, auth_allowed_origins=origin) def test_local_admin_provisioning_has_no_default_account(migrated_database, monkeypatch, capsys): from sqlalchemy.orm import sessionmaker from app import manage monkeypatch.setattr(manage, "SessionLocal", sessionmaker(bind=migrated_database)) monkeypatch.setattr("sys.argv", ["manage", "create-admin", "--username", "local_admin"]) monkeypatch.setattr(manage.getpass, "getpass", lambda _: PASSWORD) manage.main() with Session(migrated_database) as db: user = db.scalar(select(User)) assert user.role == "admin" and check_password(user.password_hash, PASSWORD) with pytest.raises(SystemExit) as error: manage.main() assert error.value.code == 1 assert PASSWORD not in capsys.readouterr().out def test_client_budget_is_shared_across_names_and_registration(anonymous_client, migrated_database): c = anonymous_client for index in range(30): assert login(c, username=f"absent_{index}").status_code == 401 assert register(c).status_code == 429 # Expire the persisted budget instead of waiting in a test. from app.models.auth import AuthRateLimit with Session(migrated_database) as db: for bucket in db.scalars(select(AuthRateLimit)): bucket.window_start = utc_now() - timedelta(minutes=6) db.commit() assert register(c).status_code == 201