Files

827 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Authentication policy, password/passphrase parity and session revocation."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from sqlalchemy import event, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import deps, langgraph_auth
from app.gateway.auth import config as auth_config
from app.gateway.auth import password as password_utils
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.auth.jwt import create_access_token, decode_token
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.models import User
from app.gateway.auth.password import hash_password, verify_password
from app.gateway.auth.repositories.base import CredentialConflictError
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.routers import auth, parallel_agents, service_tokens, system_settings
from deerflow.config import system_settings as settings
from deerflow.config.login_config import AuthLoginConfig
from deerflow.persistence.user.model import AuthInitializationRow, AuthUsernameRow, UserRow
PASSWORD = "Account-s3cret!"
NEW_PASSWORD = "Changed-s3cret!"
@pytest_asyncio.fixture
async def ctx(monkeypatch, tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'users.db'}")
async with engine.begin() as connection:
await connection.run_sync(lambda conn: UserRow.metadata.create_all(conn, tables=[UserRow.__table__, AuthInitializationRow.__table__, AuthUsernameRow.__table__]))
sf = async_sessionmaker(engine, expire_on_commit=False)
repository = SQLiteUserRepository(sf)
provider = LocalAuthProvider(repository)
monkeypatch.setattr(settings, "runtime_home", lambda: tmp_path)
monkeypatch.setattr(auth, "get_local_provider", lambda: provider)
monkeypatch.setattr(service_tokens, "get_local_provider", lambda: provider)
monkeypatch.setattr(deps, "get_local_provider", lambda: provider)
monkeypatch.setattr(langgraph_auth, "get_local_provider", lambda: provider)
monkeypatch.setattr(auth_config, "_auth_config", auth_config.AuthConfig(jwt_secret="test-auth-key-with-sufficient-entropy-0123456789"))
login_config = AuthLoginConfig()
monkeypatch.setattr(auth, "get_app_config", lambda: SimpleNamespace(auth_login=login_config))
monkeypatch.setattr(password_utils, "get_app_config", lambda: SimpleNamespace(auth_login=login_config))
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(password_login_enabled=True)))
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
monkeypatch.delenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", raising=False)
monkeypatch.setattr(auth, "_login_attempts", {})
monkeypatch.setattr(auth, "_SETUP_STATUS_COOLDOWN", {})
app = FastAPI()
app.include_router(auth.router)
app.include_router(service_tokens.router)
app.include_router(system_settings.router)
app.include_router(parallel_agents.router)
app.add_middleware(CSRFMiddleware)
app.add_middleware(AuthMiddleware)
counter = SimpleNamespace(count_owned=AsyncMock(return_value=0))
app.state.skill_store = counter
app.state.agent_store = counter
app.dependency_overrides[deps.get_skill_store] = lambda: counter
app.dependency_overrides[deps.get_agent_store] = lambda: counter
monkeypatch.setattr(auth, "get_run_store", lambda _: SimpleNamespace(count_by_user=AsyncMock(return_value={})))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
yield SimpleNamespace(client=client, app=app, provider=provider, repository=repository, sf=sf, login_config=login_config)
await engine.dispose()
@pytest.mark.asyncio
async def test_service_exchange_requires_shared_token(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
denied = await ctx.client.post(
"/api/v1/auth/service/exchange",
json={"username": "alice", "scope": ["llmwiki:sync"]},
)
assert denied.status_code == 401
assert denied.json()["detail"] == "Invalid service token"
wrong = await ctx.client.post(
"/api/v1/auth/service/exchange",
json={"username": "alice", "scope": ["llmwiki:sync"]},
headers={"X-Service-Token": "wrong-token"},
)
assert wrong.status_code == 401
response = await ctx.client.post(
"/api/v1/auth/service/exchange",
json={"username": "alice", "scope": ["llmwiki:sync"]},
headers={"X-Service-Token": service_tokens._SERVICE_TOKEN_SECRET},
)
assert response.status_code == 200, response.text
token = decode_token(response.json()["access_token"])
assert token.sub == str(user.id)
assert token.auth_method == "service"
assert token.client_id is None
assert token.scope == ["llmwiki:sync"]
assert response.json()["expires_in"] == 300
repeated = await ctx.client.post(
"/api/v1/auth/service/exchange",
json={"username": "alice", "scope": ["llmwiki:sync"]},
headers={"X-Service-Token": service_tokens._SERVICE_TOKEN_SECRET},
)
assert repeated.status_code == 200
@pytest.mark.asyncio
async def test_service_exchange_rejects_unsupported_scope(ctx):
response = await ctx.client.post(
"/api/v1/auth/service/exchange",
json={"username": "alice", "scope": ["admin"]},
headers={"X-Service-Token": service_tokens._SERVICE_TOKEN_SECRET},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_owner_lookup_batches_ids(ctx):
first = await ctx.provider.create_user("first-owner@cm.com", password=PASSWORD)
second = await ctx.provider.create_user("second-owner@cm.com", password=PASSWORD)
users = await ctx.provider.get_users_by_ids([
str(first.id), str(second.id), str(first.id), "missing-user",
])
assert {str(user.id) for user in users} == {str(first.id), str(second.id)}
@pytest.mark.asyncio
async def test_username_login_uses_exact_indexed_key_and_backfills_legacy_rows(ctx):
user = await ctx.provider.create_user("quick-login@legacy-domain.com", password=PASSWORD)
statements: list[str] = []
engine = ctx.sf.kw["bind"]
def capture_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
if statement.lstrip().upper().startswith("SELECT") and "users" in statement.lower():
statements.append(statement.lower())
event.listen(engine.sync_engine, "before_cursor_execute", capture_sql)
try:
found = await ctx.provider.find_user_by_email_local_part("quick-login")
finally:
event.remove(engine.sync_engine, "before_cursor_execute", capture_sql)
assert found is not None and found.id == user.id
assert statements and "username_key =" in statements[0]
assert "lower(users.email)" not in statements[0]
async with ctx.sf() as session:
await session.execute(update(UserRow).where(UserRow.id == str(user.id)).values(username_key=None))
await session.commit()
found_after_legacy_fallback = await ctx.provider.find_user_by_email_local_part("QUICK-LOGIN")
assert found_after_legacy_fallback is not None and found_after_legacy_fallback.id == user.id
async with ctx.sf() as session:
saved_key = await session.scalar(select(UserRow.username_key).where(UserRow.id == str(user.id)))
assert saved_key == "quick-login"
@pytest.mark.parametrize(
("method", "path", "allowed"),
[
("GET", "/api/llmwiki/knowledge-bases", True),
("POST", "/api/llmwiki/knowledge-bases/kb/documents", True),
("POST", "/api/llmwiki/knowledge-bases/kb/documents/manual", True),
("POST", "/api/llmwiki/search", False),
("POST", "/api/threads/thread/runs", False),
("GET", "/api/v1/auth/me", False),
],
)
def test_service_scope_path_allowlist(method, path, allowed):
from app.gateway.auth_middleware import _is_llmwiki_sync_request
assert _is_llmwiki_sync_request(method, path) is allowed
@pytest.mark.parametrize(
("scopes", "method", "path", "all_requested", "allowed"),
[
(["recommended-questions:read"], "GET", "/api/recommended-questions", False, True),
(["recommended-questions:read"], "GET", "/api/recommended-questions", True, False),
(["recommended-questions:read"], "POST", "/api/recommended-questions", False, False),
(["recommended-questions:read"], "GET", "/api/recommended-questions-reorder", False, False),
(["recommended-questions:read"], "GET", "/api/llmwiki/knowledge-bases", False, False),
(["llmwiki:sync"], "GET", "/api/recommended-questions", False, False),
],
)
def test_recommended_question_service_scope_is_read_only(scopes, method, path, all_requested, allowed):
from app.gateway.auth_middleware import _is_service_scope_request
assert _is_service_scope_request(scopes, method, path, all_requested=all_requested) is allowed
@pytest.mark.asyncio
async def test_service_token_is_rejected_outside_llmwiki_sync(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
token = create_access_token(
str(user.id),
expires_delta=service_tokens.timedelta(seconds=300),
token_version=user.token_version,
auth_method="service",
client_id="surfsense",
scope=["llmwiki:sync"],
)
response = await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 403
def enable_registration():
current = settings.load_system_settings()
settings.save_system_settings(current.model_copy(update={"authentication": settings.AuthenticationSettings(password_login_enabled=True, registration_enabled=True)}))
def bearer(user, *, auth_method="password"):
return {"Authorization": f"Bearer {create_access_token(str(user.id), token_version=user.token_version, auth_method=auth_method)}"}
async def make_admin(ctx, *, password=PASSWORD):
return await ctx.provider.create_user("operator@cm.com", password=password, system_role="admin")
@pytest.mark.asyncio
async def test_login_identity_and_user_management_do_not_use_checkpointer(ctx, monkeypatch):
def forbidden(*args, **kwargs):
raise AssertionError("Authentication accessed the chat checkpoint backend")
monkeypatch.setattr(deps, "get_checkpointer", forbidden)
ctx.app.state.checkpointer = SimpleNamespace(aget_tuple=forbidden, alist=forbidden, aput=forbidden)
user = await make_admin(ctx)
ctx.login_config.token_login.enabled = True
ctx.login_config.token_login.mock_tokens = {"audit-token": "operator"}
assert (await ctx.client.get("/api/v1/auth/options")).status_code == 200
for route, kwargs in [
("local", {"data": {"username": "operator", "password": PASSWORD}}),
("username", {"json": {"username": "operator"}, "params": {"password": PASSWORD}}),
("token", {"params": {"token": "audit-token"}}),
]:
response = await ctx.client.post(f"/api/v1/auth/login/{route}", **kwargs)
assert response.status_code == 200, response.text
headers = {"Authorization": f"Bearer {response.json()['access_token']}"}
assert (await ctx.client.get("/api/v1/auth/me", headers=headers)).json()["id"] == str(user.id)
assert (await ctx.client.get("/api/v1/auth/users", headers=headers)).status_code == 200
@pytest.mark.asyncio
async def test_list_users_paged_shape_and_search(ctx):
"""传 limit → UserListPage(items/total/approved_count);不传 → 旧全量列表。"""
admin = await make_admin(ctx)
for name in ("bob@cm.com", "carol@cm.com"):
await ctx.provider.create_user(name, password=PASSWORD)
await ctx.provider.create_user("dave@cm.com", password=PASSWORD) # 将被取消放行
dave = await ctx.provider.get_user_by_email("dave@cm.com")
dave.approved = False
await ctx.provider.update_user(dave)
headers = bearer(admin)
legacy = await ctx.client.get("/api/v1/auth/users", headers=headers)
assert legacy.status_code == 200
assert isinstance(legacy.json(), list) and len(legacy.json()) == 4
paged = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 2, "offset": 0, "include_counts": "false"})
assert paged.status_code == 200, paged.text
body = paged.json()
assert body["total"] == 4 and body["approved_count"] == 3 # admin+bob+carol 可登录,dave 被拦
assert body["limit"] == 2 and body["offset"] == 0
assert len(body["items"]) == 2
assert all(item["qa_count"] == 0 and item["skill_count"] == 0 for item in body["items"])
page2 = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 2, "offset": 2})
assert page2.status_code == 200 and len(page2.json()["items"]) == 2
searched = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 10, "q": "dave"})
assert searched.status_code == 200
assert searched.json()["total"] == 1 and searched.json()["items"][0]["email"] == "dave@cm.com"
assert searched.json()["approved_count"] == 0 # 搜索范围内唯一用户已被取消放行
miss = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 10, "offset": 999})
assert miss.status_code == 200 and miss.json()["items"] == [] and miss.json()["total"] == 4
@pytest.mark.asyncio
async def test_anonymous_options_defaults_and_closed_registration(ctx):
settings.save_system_settings(settings.SystemSettings())
options = await ctx.client.get("/api/v1/auth/options")
assert options.status_code == 200
assert options.json() == {"password_login_enabled": False, "registration_enabled": False, "sso_enabled": True, "single_session_enabled": False, "needs_setup": True}
assert options.headers["cache-control"] == "no-store"
response = await ctx.client.post("/api/v1/auth/register", json={"username": "alice", "password": PASSWORD})
assert response.status_code == 403
assert await ctx.provider.count_users() == 0
@pytest.mark.asyncio
async def test_register_login_and_sso_share_password_and_user_role(ctx):
enable_registration()
response = await ctx.client.post("/api/v1/auth/register", json={"username": "b1admin", "password": PASSWORD})
assert response.status_code == 201, response.text
assert response.json()["system_role"] == "user"
assert response.json()["access_token"]
assert decode_token(response.json()["access_token"]).auth_method == "password"
user = await ctx.provider.get_user(response.json()["user_id"])
assert user.password_hash == user.login_password
assert user.login_password != PASSWORD
assert verify_password(PASSWORD, user.password_hash)
for username in ("b1admin", "B1ADMIN@CM.COM"):
login = await ctx.client.post("/api/v1/auth/login/local", data={"username": username, "password": PASSWORD})
assert login.status_code == 200
assert login.json()["user_id"] == str(user.id)
denied = await ctx.client.post("/api/v1/auth/login/username", json={"username": "b1admin"})
assert denied.status_code == 401
sso = await ctx.client.post("/api/v1/auth/login/username", params={"password": PASSWORD}, json={"username": "b1admin"})
assert sso.status_code == 200
assert decode_token(sso.json()["access_token"]).auth_method == "passphrase"
assert sso.json()["system_role"] == "user"
me = await ctx.client.get("/api/v1/auth/me", headers=bearer(user))
assert me.json()["has_password"] is True
assert "password_hash" not in me.json()
@pytest.mark.asyncio
@pytest.mark.parametrize("username", ["alice", "ALICE", "alice@other.org"])
async def test_registration_rejects_legacy_username_collisions(ctx, username):
enable_registration()
legacy = await ctx.provider.create_user("alice@legacy.com", password=PASSWORD)
response = await ctx.client.post("/api/v1/auth/register", json={"username": username, "password": NEW_PASSWORD})
assert response.status_code == 409
assert await ctx.provider.count_users() == 1
assert verify_password(PASSWORD, (await ctx.provider.get_user(str(legacy.id))).password_hash)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body",
[
{"username": "alice", "password": "password123"},
{"username": "alice", "password": "short"},
{"username": "alice", "password": "x" * 256},
{"username": "alice", "password": " Account-s3cret!"},
{"username": "bad name", "password": PASSWORD},
{"username": "alice", "password": PASSWORD, "system_role": "admin"},
{"username": "alice", "password": PASSWORD, "email": "different@example.com"},
],
)
async def test_register_validates_credentials_and_rejects_role_injection(ctx, body):
enable_registration()
response = await ctx.client.post("/api/v1/auth/register", json=body)
assert response.status_code == 422
assert await ctx.provider.count_users() == 0
@pytest.mark.asyncio
async def test_policy_admin_only_and_lockout_protection(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
policy = {"password_login_enabled": True, "registration_enabled": True, "sso_enabled": False}
assert (await ctx.client.get("/api/system-settings/authentication")).status_code == 401
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(user), json=policy)).status_code == 403
admin = await make_admin(ctx, password=None)
# The existing shared/default passphrase remains usable without a personal password.
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json=policy)).status_code == 200
for invalid in [dict(policy, password_login_enabled=False), dict(policy, registration_enabled=False, password_login_enabled=False)]:
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json=invalid)).status_code == 400
changed = await ctx.client.post("/api/v1/auth/change-password", headers=bearer(admin), json={"current_password": "123ewq", "new_password": PASSWORD})
assert changed.status_code == 200
assert decode_token(changed.json()["access_token"]).auth_method == "password"
admin = await ctx.provider.get_user(str(admin.id))
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json=policy)).status_code == 200
for path, kwargs in [
("/api/v1/auth/login/username", {"json": {"username": "newbie"}}),
("/api/v1/auth/login/token", {"params": {"token": "upstream"}}),
]:
assert (await ctx.client.post(path, **kwargs)).status_code == 403
# Detail integrations use the legacy token endpoint as an unconditional
# preflight. Policy rejection returns a non-JWT compatibility value.
compat = await ctx.client.post("/api/parallel-agents/auth/token", json={"username": "newbie"})
assert compat.status_code == 200
assert compat.json()["access_token"] == "force-situation-detail-compat-newbie"
assert await ctx.provider.count_users() == 2
assert (await ctx.client.get("/api/v1/auth/oauth/github", headers=bearer(admin))).status_code == 403
@pytest.mark.asyncio
async def test_disabling_password_login_blocks_login_and_registration(ctx):
admin = await make_admin(ctx)
response = await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json={"password_login_enabled": False, "registration_enabled": False, "sso_enabled": True})
assert response.status_code == 200
assert (await ctx.client.post("/api/v1/auth/login/local", data={"username": "operator", "password": PASSWORD})).status_code == 403
assert (await ctx.client.post("/api/v1/auth/register", json={"username": "alice", "password": PASSWORD})).status_code == 403
@pytest.mark.asyncio
async def test_change_password_revokes_old_sessions_and_keeps_current_bearer(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
old_headers = bearer(user)
wrong = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": "wrong", "new_password": NEW_PASSWORD})
assert wrong.status_code == 400
changed = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": PASSWORD, "new_password": NEW_PASSWORD})
assert changed.status_code == 200
assert (await ctx.client.get("/api/v1/auth/me", headers=old_headers)).status_code == 401
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": "Bearer " + changed.json()["access_token"]})).status_code == 200
saved = await ctx.provider.get_user(str(user.id))
assert saved.token_version == 1
assert saved.login_password == saved.password_hash
for password, expected in [(PASSWORD, 401), (NEW_PASSWORD, 200)]:
assert (await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": password})).status_code == expected
assert (await ctx.client.post("/api/v1/auth/login/username", params={"password": password}, json={"username": "alice"})).status_code == expected
@pytest.mark.asyncio
async def test_admin_reset_unified_password_no_hash_leak_and_self_session(ctx):
admin = await make_admin(ctx)
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
old_headers = bearer(user)
endpoint = f"/api/v1/auth/users/{user.id}/reset-password"
assert (await ctx.client.put(endpoint, headers=old_headers, json={"password": NEW_PASSWORD})).status_code == 403
response = await ctx.client.put(endpoint, headers=bearer(admin), json={"password": NEW_PASSWORD})
assert response.status_code == 200
assert response.json()["login_password"] is None
assert response.json()["has_login_password"] is True
assert (await ctx.client.get("/api/v1/auth/me", headers=old_headers)).status_code == 401
listing = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin))
assert listing.status_code == 200
assert "$dfv" not in listing.text and NEW_PASSWORD not in listing.text
assert (await ctx.client.put(endpoint, headers=bearer(admin), json={"password": None})).status_code == 400
old_admin = bearer(admin)
self_reset = await ctx.client.put(f"/api/v1/auth/users/{admin.id}/login-password", headers=old_admin, json={"password": NEW_PASSWORD})
assert self_reset.status_code == 200
assert self_reset.json()["access_token"]
assert decode_token(self_reset.json()["access_token"]).auth_method == "password"
assert (await ctx.client.get("/api/v1/auth/me", headers=old_admin)).status_code == 401
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": "Bearer " + self_reset.json()["access_token"]})).status_code == 200
@pytest.mark.asyncio
@pytest.mark.parametrize("legacy_password", ["old-code", "$dfv-legacy-plaintext"])
async def test_historical_plaintext_passphrase_wins_and_changes_without_old_hash(ctx, legacy_password):
user = User(email="legacy@cm.com", password_hash=hash_password("different-old-password"), login_password=legacy_password)
await ctx.repository.create_user(user)
login = await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": legacy_password})
assert login.status_code == 200
assert (await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": "different-old-password"})).status_code == 401
change = await ctx.client.post("/api/v1/auth/change-password", headers=bearer(user), json={"current_password": legacy_password, "new_password": NEW_PASSWORD})
assert change.status_code == 200
saved = await ctx.provider.get_user(str(user.id))
assert saved.login_password == saved.password_hash
assert verify_password(NEW_PASSWORD, saved.login_password)
@pytest.mark.asyncio
async def test_parallel_token_endpoint_returns_non_jwt_compat_value_when_login_is_rejected(ctx):
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
endpoint = "/api/parallel-agents/auth/token"
fallback = await ctx.client.post(endpoint, json={"username": "alice"})
assert fallback.status_code == 200
assert fallback.json()["access_token"] == "force-situation-detail-compat-alice"
assert (await ctx.client.post(endpoint, params={"password": PASSWORD}, json={"username": "alice"})).status_code == 200
@pytest.mark.asyncio
async def test_parallel_token_endpoint_returns_compat_value_for_any_rejected_detail_preflight(ctx):
"""Every old detail client passes its preflight without a real login session."""
from app.gateway.auth import TokenError, decode_token
endpoint = "/api/parallel-agents/auth/token"
for username in ("fs-collect", "fs-detail-read", "fs-detail-submit", "fs-detail-update", "task-detail-agent"):
response = await ctx.client.post(endpoint, json={"username": username})
assert response.status_code == 200
payload = response.json()
assert payload["access_token"] == f"force-situation-detail-compat-{username}"
assert payload["user_id"] == f"external:{username}"
# The compatibility value cannot authenticate unrelated APIs.
assert isinstance(decode_token(payload["access_token"]), TokenError)
@pytest.mark.asyncio
async def test_approval_gate_applies_to_password_login(ctx):
await ctx.provider.create_user("blocked@cm.com", password=PASSWORD, approved=False)
current = settings.load_system_settings()
settings.save_system_settings(current.model_copy(update={"login_whitelist": settings.LoginWhitelistSettings(enabled=True)}))
response = await ctx.client.post("/api/v1/auth/login/local", data={"username": "blocked", "password": PASSWORD})
assert response.status_code == 403
assert response.json()["detail"]["code"] == "PENDING_APPROVAL"
@pytest.mark.asyncio
async def test_stale_profile_and_concurrent_password_updates_cannot_resurrect_credentials(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
stale = user.model_copy(deep=True)
await ctx.repository.update_credentials(user, hash_password(NEW_PASSWORD))
stale.approved = False
with pytest.raises(CredentialConflictError):
await ctx.repository.update_user(stale)
with pytest.raises(CredentialConflictError):
await ctx.repository.update_credentials(stale, hash_password("Concurrent-s3cret!"))
saved = await ctx.provider.get_user(str(user.id))
assert saved.token_version == 1
assert verify_password(NEW_PASSWORD, saved.login_password)
@pytest.mark.asyncio
async def test_initialize_first_admin_is_atomic_and_returns_login_credentials(ctx):
results = await asyncio.gather(*[ctx.client.post("/api/v1/auth/initialize", json={"email": f"initial{index}@cm.com", "password": PASSWORD}) for index in range(2)])
assert sorted(response.status_code for response in results) == [201, 409]
assert await ctx.provider.count_admin_users() == 1
success = next(response for response in results if response.status_code == 201)
assert success.json()["access_token"]
assert decode_token(success.json()["access_token"]).auth_method == "password"
assert success.json()["system_role"] == "admin"
options = await ctx.client.get("/api/v1/auth/options")
assert options.json()["needs_setup"] is False
@pytest.mark.asyncio
async def test_sql_credential_rotation_updates_both_columns_without_touching_approval(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
async with ctx.sf() as session:
await session.execute(update(UserRow).where(UserRow.id == str(user.id)).values(approved=False))
await session.commit()
saved = await ctx.provider.update_credentials(user, hash_password(NEW_PASSWORD))
persisted = await ctx.provider.get_user(str(saved.id))
assert persisted.approved is False
assert persisted.password_hash == persisted.login_password
assert persisted.token_version == 1
@pytest.mark.asyncio
async def test_concurrent_registration_email_domains_cannot_claim_same_username(ctx):
enable_registration()
results = await asyncio.gather(*[ctx.client.post("/api/v1/auth/register", json={"email": f"alice@domain{index}.com", "password": PASSWORD}) for index in range(2)])
assert sorted(response.status_code for response in results) == [201, 409]
assert await ctx.provider.count_users() == 1
@pytest.mark.asyncio
async def test_username_login_accepts_form_body(ctx):
response = await ctx.client.post(
"/api/v1/auth/login/username",
data={"username": "firefox-legacy"},
params={"password": "123ewq"},
)
assert response.status_code == 200, response.text
assert response.json()["username"] == "firefox-legacy"
blank = await ctx.client.post("/api/v1/auth/login/username", data={"username": ""})
assert blank.status_code == 422
@pytest.mark.asyncio
async def test_username_sso_rechecks_password_if_registration_won_a_race(ctx, monkeypatch):
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
monkeypatch.setattr(auth, "_lookup_user_by_username", AsyncMock(return_value=None))
response = await ctx.client.post("/api/v1/auth/login/username", json={"username": "alice"})
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.parametrize("configured,expected_password", [("", "123ewq"), ("Original-shared-code", "Original-shared-code")])
async def test_accounts_without_personal_password_use_existing_passphrase(ctx, configured, expected_password):
ctx.login_config.username_login.password = configured
user = await ctx.provider.create_user("legacy@cm.com")
assert not ctx.login_config.username_login.require_password
assert await ctx.provider.authenticate({"email": user.email, "password": "wrong"}) is None
assert (await ctx.provider.authenticate({"email": user.email, "password": expected_password})).id == user.id
for path, kwargs in [
("/api/v1/auth/login/local", {"data": {"username": "legacy", "password": expected_password}}),
("/api/v1/auth/login/username", {"json": {"username": "legacy"}, "params": {"password": expected_password}}),
("/api/parallel-agents/auth/token", {"json": {"username": "legacy"}, "params": {"password": expected_password}}),
]:
response = await ctx.client.post(path, **kwargs)
assert response.status_code == 200, response.text
assert decode_token(response.json()["access_token"]).auth_method in {"password", "passphrase"}
me = await ctx.client.get("/api/v1/auth/me", headers=bearer(user))
assert me.json()["has_password"] is False
assert me.json()["uses_default_password"] is True
assert me.json()["requires_current_password"] is True
if configured:
denied = await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": "123ewq"})
assert denied.status_code == 401
@pytest.mark.asyncio
async def test_first_personal_password_requires_current_shared_passphrase(ctx):
user = await ctx.provider.create_user("legacy@cm.com")
old_headers = bearer(user)
for wrong in ("", "incorrect"):
response = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": wrong, "new_password": NEW_PASSWORD})
assert response.status_code == 400
changed = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": "123ewq", "new_password": NEW_PASSWORD})
assert changed.status_code == 200
for path, kwargs in [
("/api/v1/auth/login/local", {"data": {"username": "legacy", "password": "123ewq"}}),
("/api/v1/auth/login/username", {"json": {"username": "legacy"}, "params": {"password": "123ewq"}}),
]:
assert (await ctx.client.post(path, **kwargs)).status_code == 401
updated = await ctx.provider.get_user(str(user.id))
assert await ctx.provider.authenticate({"email": user.email, "password": "123ewq"}) is None
me = await ctx.client.get("/api/v1/auth/me", headers=bearer(updated))
assert me.json()["has_password"] is True
assert me.json()["uses_default_password"] is False
@pytest.mark.asyncio
async def test_enabled_password_login_blocks_bare_username_and_parallel_logins(ctx):
await ctx.provider.create_user("legacy@cm.com")
for username in ("legacy", "not-created"):
assert (await ctx.client.post("/api/v1/auth/login/username", json={"username": username})).status_code == 401
compat = await ctx.client.post("/api/parallel-agents/auth/token", json={"username": username})
assert compat.status_code == 200
assert compat.json()["access_token"] == f"force-situation-detail-compat-{username}"
assert await ctx.provider.count_users() == 1
def langgraph_request(token):
return SimpleNamespace(method="GET", headers={"authorization": f"Bearer {token}"}, cookies={})
@pytest.mark.asyncio
async def test_gateway_and_langgraph_reject_legacy_session_after_login_enabled(ctx):
user = await ctx.provider.create_user("legacy@cm.com")
legacy = create_access_token(str(user.id), token_version=user.token_version)
assert decode_token(legacy).auth_method is None
for cookie_only in (False, True):
ctx.client.cookies.clear()
if cookie_only:
ctx.client.cookies.set("access_token", legacy)
headers = {} if cookie_only else {"Authorization": f"Bearer {legacy}"}
assert (await ctx.client.get("/api/v1/auth/me", headers=headers)).status_code == 401
with pytest.raises(Exception) as rejected:
await langgraph_auth.authenticate(langgraph_request(legacy))
assert rejected.value.status_code == 401
login = await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": "123ewq"})
assert login.status_code == 200
fresh = login.json()["access_token"]
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {fresh}"})).status_code == 200
assert await langgraph_auth.authenticate(langgraph_request(fresh)) == str(user.id)
@pytest.mark.asyncio
async def test_passwordless_compatibility_token_cannot_survive_enabling_password_login(ctx):
settings.save_system_settings(settings.SystemSettings())
response = await ctx.client.post("/api/v1/auth/login/username", json={"username": "legacy"})
assert response.status_code == 200
token = response.json()["access_token"]
assert decode_token(token).auth_method is None
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 200
assert await langgraph_auth.authenticate(langgraph_request(token)) == response.json()["user_id"]
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(password_login_enabled=True)))
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 401
with pytest.raises(Exception) as rejected:
await langgraph_auth.authenticate(langgraph_request(token))
assert rejected.value.status_code == 401
@pytest.mark.asyncio
async def test_verified_upstream_sso_remains_independently_controlled(ctx):
ctx.login_config.token_login.enabled = True
ctx.login_config.token_login.mock_tokens = {"trusted-upstream-token": "sso-user"}
response = await ctx.client.post("/api/v1/auth/login/token", params={"token": "trusted-upstream-token"})
assert response.status_code == 200
token = response.json()["access_token"]
assert decode_token(token).auth_method == "sso"
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 200
assert await langgraph_auth.authenticate(langgraph_request(token)) == response.json()["user_id"]
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(password_login_enabled=True, sso_enabled=False)))
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 401
with pytest.raises(Exception) as rejected:
await langgraph_auth.authenticate(langgraph_request(token))
assert rejected.value.status_code == 401
@pytest.mark.asyncio
@pytest.mark.parametrize("policy_field", ["password_login_enabled", "single_session_enabled"])
async def test_development_bypass_does_not_override_enabled_login(ctx, monkeypatch, policy_field):
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(**{policy_field: True})))
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "true")
assert is_auth_disabled() is False
assert (await ctx.client.get("/api/v1/auth/me")).status_code == 401
with pytest.raises(Exception) as rejected:
await langgraph_auth.authenticate(langgraph_request(""))
assert rejected.value.status_code == 401
settings.save_system_settings(settings.SystemSettings())
assert is_auth_disabled() is True
def set_single_session(enabled):
current = settings.load_system_settings()
policy = current.authentication.model_copy(update={"single_session_enabled": enabled})
settings.save_system_settings(current.model_copy(update={"authentication": policy}))
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("method", ["local", "username", "token", "parallel"])
async def test_login_single_session_policy_across_all_entry_points(ctx, enabled, method):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
set_single_session(enabled)
ctx.login_config.token_login.enabled = True
ctx.login_config.token_login.mock_tokens = {"upstream-alice": "alice"}
async def login():
if method == "local":
return await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": PASSWORD})
if method == "token":
return await ctx.client.post("/api/v1/auth/login/token", params={"token": "upstream-alice"})
endpoint = "/api/parallel-agents/auth/token" if method == "parallel" else "/api/v1/auth/login/username"
return await ctx.client.post(endpoint, json={"username": "alice"}, params={"password": PASSWORD})
first, second = await login(), await login()
assert first.status_code == second.status_code == 200
first_token, second_token = first.json()["access_token"], second.json()["access_token"]
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {first_token}"})).status_code == (401 if enabled else 200)
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {second_token}"})).status_code == 200
if enabled:
assert decode_token(second_token).ver == decode_token(first_token).ver + 1
with pytest.raises(Exception) as rejected:
await langgraph_auth.authenticate(langgraph_request(first_token))
assert rejected.value.status_code == 401
else:
assert decode_token(first_token).ver == decode_token(second_token).ver == 0
assert await langgraph_auth.authenticate(langgraph_request(first_token)) == str(user.id)
assert await langgraph_auth.authenticate(langgraph_request(second_token)) == str(user.id)
@pytest.mark.asyncio
async def test_failed_login_does_not_kick_out_existing_session(ctx):
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
set_single_session(True)
first = await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": PASSWORD})
token = first.json()["access_token"]
failed = await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": "incorrect"})
assert failed.status_code == 401
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 200
@pytest.mark.asyncio
async def test_concurrent_logins_have_exactly_one_surviving_session(ctx):
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
set_single_session(True)
responses = await asyncio.gather(*[ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": PASSWORD}) for _ in range(2)])
assert all(response.status_code == 200 for response in responses)
tokens = [response.json()["access_token"] for response in responses]
assert sorted(decode_token(token).ver for token in tokens) == [1, 2]
checks = [await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) for token in tokens]
assert sorted(response.status_code for response in checks) == [200, 401]
@pytest.mark.asyncio
async def test_single_session_cannot_reissue_after_concurrent_password_reset(ctx):
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
stale = user.model_copy(deep=True)
await ctx.repository.update_credentials(user, hash_password(NEW_PASSWORD))
with pytest.raises(CredentialConflictError):
await ctx.repository.start_login_session(stale)
persisted = await ctx.provider.get_user(str(user.id))
assert persisted.token_version == 1
assert verify_password(NEW_PASSWORD, persisted.password_hash)
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["register", "initialize"])
async def test_first_account_login_obeys_single_session_setting(ctx, method):
enable_registration()
set_single_session(True)
body = {"password": PASSWORD, "username" if method == "register" else "email": "first" if method == "register" else "first@cm.com"}
first = await ctx.client.post(f"/api/v1/auth/{method}", json=body)
assert first.status_code == 201
old_token = first.json()["access_token"]
assert decode_token(old_token).ver == 1
second = await ctx.client.post("/api/v1/auth/login/local", data={"username": "first", "password": PASSWORD})
assert second.status_code == 200
assert decode_token(second.json()["access_token"]).ver == 2
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {old_token}"})).status_code == 401
@pytest.mark.asyncio
async def test_whitelist_is_independent_of_statistics_stores(ctx, monkeypatch):
admin = await make_admin(ctx)
def forbidden(*args, **kwargs):
raise AssertionError("Whitelist must not resolve statistics stores")
for name in ("get_skill_store", "get_agent_store", "get_run_store"):
monkeypatch.setattr(auth, name, forbidden)
response = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"})
assert response.status_code == 200, response.text
assert response.json()["total"] == 1
assert response.json()["items"][0]["login_password"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["list_users_page", "count_users_filtered"])
async def test_whitelist_timeout_cancels_account_queries(ctx, monkeypatch, method):
admin = await make_admin(ctx)
cancelled = asyncio.Event()
async def stalled(*args, **kwargs):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(ctx.provider, method, stalled)
monkeypatch.setattr(auth, "_USER_LIST_TIMEOUT_SECONDS", 0.05)
response = await asyncio.wait_for(ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"}), 5)
assert response.status_code == 503
assert "超时" in response.json()["detail"]
assert cancelled.is_set()
@pytest.mark.asyncio
async def test_whitelist_paged_is_admin_only(ctx):
user = await ctx.provider.create_user("normal-whitelist@cm.com", password=PASSWORD)
params = {"limit": 20, "include_counts": "false"}
assert (await ctx.client.get("/api/v1/auth/users", headers=bearer(user), params=params)).status_code == 403
assert (await ctx.client.get("/api/v1/auth/users", params=params)).status_code == 401
@pytest.mark.asyncio
async def test_whitelist_loads_when_resource_stores_unavailable(ctx):
admin = await make_admin(ctx)
del ctx.app.state.skill_store
del ctx.app.state.agent_store
response = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"})
assert response.status_code == 200
assert response.json()["total"] == 1
# The full statistics endpoint still requires the resource stores.
assert (await ctx.client.get("/api/v1/auth/users", headers=bearer(admin))).status_code == 503