"""身份三种模式(应用平台 A7):off 默认、local、oidc。 要守住的: - 关登录(off)时一切照旧:X-Actor 自报署名,所有接口放行,非 GET 不用带任何新请求头; - 从 off 切到 local 必须同一个请求建出第一个管理员,建好就以他登录; - 开登录后:没登录 401、角色不够 403;非 GET 要带 X-AgentLab-Request 头、Origin 不能是别的站点; 伪造的 X-Actor 一律不看,审计记的是认证过的用户名;登录失败退避;会话闲置过期、登出、停用都立刻失效; - 环境变量指定的登录方式不能在设置里改;只有 local 模式下、还没有管理员时能 bootstrap; - oidc:授权码 + PKCE,id_token 按 JWKS 核验,state 只能用一次,groups 同步成只读的组、按映射定角色; - python -m app.auth reset-admin 能找回管理员; - WebSocket 只给 builder 及以上。 """ from __future__ import annotations from datetime import timedelta import httpx import pytest from sqlalchemy import select, update from app.api.deps import Principal, actor_of, reset_principal, set_principal, signer_of from app.db.base import SessionLocal, utcnow from app.db.models import AuditEvent, AuthSession, Group, User from tests.fakes import auth_env from tests.fakes.fake_oidc import FakeOidc @pytest.fixture(autouse=True) async def clean(): await auth_env.reset() try: yield finally: await auth_env.reset() # ---------------------------------------------------------------- off async def test_off_mode_behaves_as_before(): c = auth_env.client() c.headers.pop("X-AgentLab-Request") me = (await c.get("/api/auth/me", headers={"X-Actor": "%E5%BC%A0%E4%B8%89"})).json() assert me == {**me, "mode": "off", "role": "admin", "username": "张三", "verified": False} # 管理员接口、非 GET 都不用登录、不用新请求头 assert (await c.get("/api/users")).status_code == 200 r = await c.post("/api/groups", json={"name": "ops"}, headers={"X-Actor": "lee"}) assert r.status_code == 201, r.text async with SessionLocal() as s: audit = (await s.execute(select(AuditEvent).where(AuditEvent.action == "group.create"))).scalar_one() assert (audit.actor, audit.verified) == ("lee", False), "关登录时审计照实记自报的署名、未认证" async def test_actor_of_ignores_header_once_logged_in(): token = set_principal(Principal(username="alice", role="user", verified=True, mode="local")) try: assert actor_of("mallory") == "alice" assert signer_of("请求体署名", "mallory") == "alice", "开了登录:数据导入这类请求体里自带的署名也不看" finally: reset_principal(token) assert actor_of("mallory") == "mallory" assert signer_of(" 请求体署名 ", "mallory") == "请求体署名" and signer_of("", "mallory") == "mallory", \ "关登录:请求体里填的优先,其次 X-Actor" # ---------------------------------------------------------------- local async def test_switching_to_local_needs_the_first_admin_in_the_same_request(): c = auth_env.client() r = await c.put("/api/auth/settings", json={"mode": "local"}) assert r.status_code == 422 and "第一个管理员" in r.json()["detail"] r = await c.put("/api/auth/settings", json={"mode": "local", "bootstrap": {"username": "root", "password": "short"}}) assert r.status_code == 422, "密码太短也不能切过去" r = await c.put("/api/auth/settings", json={"mode": "local", "bootstrap": {"username": "root", "password": "password-123"}}) assert r.status_code == 200, r.text assert r.json()["principal"]["role"] == "admin" me = (await c.get("/api/auth/me")).json() assert (me["mode"], me["username"], me["verified"]) == ("local", "root", True), "建出来就是登录状态" async with SessionLocal() as s: row = (await s.execute(select(AuthSession))).scalar_one() assert len(row.token_hash) == 64 and c.cookies.get("agentlab_session") not in (None, row.token_hash), \ "库里只存 token 的 sha256" async def test_switching_to_local_with_an_admin_who_has_a_password_does_not_ask_for_another(): """关登录时已经建了设过密码的管理员:设置页不该再要求建第一个管理员,切过去用他登录。""" await auth_env.make_user("ada", "admin") await auth_env.make_user("bo", "builder") c = auth_env.client() got = (await c.get("/api/auth/settings")).json() assert got["local_admins"] == [{"username": "ada", "display_name": "ada"}], got assert got["passwordless_admins"] == [] r = await c.put("/api/auth/settings", json={"mode": "local"}) assert r.status_code == 200, r.text assert "principal" not in r.json(), "没新建管理员就不替谁登录:切过去回登录页" assert (await c.get("/api/auth/me")).status_code == 401 assert (await (await auth_env.login("ada")).get("/api/auth/me")).json()["role"] == "admin" async def test_an_admin_without_a_password_is_named_and_still_needs_the_first_admin(): """关登录时新建用户可以不填密码:这样的管理员切过去登录不了,要建一个能登录的,并说清是谁没设密码。""" async with SessionLocal() as s: s.add(User(username="nopw", display_name="没设密码的", role="admin", password_hash=None)) s.add(User(username="gone", display_name="停用的", role="admin", password_hash=None, disabled=True)) await s.commit() c = auth_env.client() got = (await c.get("/api/auth/settings")).json() assert got["local_admins"] == [] and got["passwordless_admins"] == ["nopw"], got r = await c.put("/api/auth/settings", json={"mode": "local"}) assert r.status_code == 422 and "第一个管理员" in r.json()["detail"] async def test_roles_401_403_and_forged_actor(): admin = await auth_env.turn_on_local() await auth_env.make_user("bob", "builder") await auth_env.make_user("uma", "user") anon = auth_env.client() r = await anon.get("/api/workflows") assert r.status_code == 401 me = await anon.get("/api/auth/me") assert me.status_code == 401 and me.json()["mode"] == "local", "登录页据此显示账号密码" assert me.json()["can_bootstrap"] is False, "已经有管理员:登录页不给建管理员" uma = await auth_env.login("uma") assert (await uma.get("/api/workflows")).status_code == 403 assert (await uma.get("/api/users")).status_code == 403 bob = await auth_env.login("bob") assert (await bob.get("/api/workflows")).status_code == 200 assert (await bob.get("/api/users")).status_code == 403, "用户、组、审计只给管理员" r = await admin.post("/api/groups", json={"name": "fin"}, headers={"X-Actor": "mallory"}) assert r.status_code == 201 async with SessionLocal() as s: audit = (await s.execute(select(AuditEvent).where(AuditEvent.action == "group.create"))).scalar_one() assert (audit.actor, audit.verified) == ("root", True), "伪造的 X-Actor 被忽略,审计照实记" async def test_directory_is_read_only_for_builders_and_leaves_out_the_rest(): admin = await auth_env.turn_on_local() await auth_env.make_user("bob", "builder", email="bob@example.com") await auth_env.make_user("uma", "user", disabled=True) assert (await admin.post("/api/groups", json={"name": "fin", "label": "财务"})).status_code == 201 bob = await auth_env.login("bob") r = await bob.get("/api/directory") assert r.status_code == 200, "搭建者绑岗位、授权要选人:名录给搭建者" body = r.json() assert {u["username"]: u["disabled"] for u in body["users"]} == {"root": False, "bob": False, "uma": True}, \ "停用的人也列出来,标明停用" assert set(body["users"][0]) == {"username", "display_name", "disabled"}, "不含邮箱、角色这些" assert body["groups"] == [{"name": "fin", "label": "财务"}] assert (await bob.post("/api/directory", json={})).status_code == 405, "只读" async with SessionLocal() as s: await s.execute(update(User).where(User.username == "uma").values(disabled=False)) await s.commit() uma = await auth_env.login("uma") assert (await uma.get("/api/directory")).status_code == 403, "使用者不给" async def test_csrf_header_and_origin(): admin = await auth_env.turn_on_local() r = await admin.post("/api/groups", json={"name": "a"}, headers={"X-AgentLab-Request": ""}) # 头带了但为空也算带了;去掉才算没带 del admin.headers["X-AgentLab-Request"] r = await admin.post("/api/groups", json={"name": "b"}) assert r.status_code == 403 and r.json()["code"] == "csrf_header" admin.headers["X-AgentLab-Request"] = "1" r = await admin.post("/api/groups", json={"name": "c"}, headers={"Origin": "https://evil.example"}) assert r.status_code == 403 and r.json()["code"] == "csrf_origin" r = await admin.post("/api/groups", json={"name": "d"}, headers={"Origin": "http://test"}) assert r.status_code == 201 # GET 不查 del admin.headers["X-AgentLab-Request"] assert (await admin.get("/api/groups")).status_code == 200 async def test_login_backoff_logout_idle_and_disabled(): await auth_env.turn_on_local() await auth_env.make_user("uma", "user") c = auth_env.client() for _ in range(5): assert (await c.post("/api/auth/login", json={"username": "uma", "password": "wrong-pass"})).status_code == 401 r = await c.post("/api/auth/login", json={"username": "uma", "password": "password-123"}) assert r.status_code == 429, "连错之后要等一会儿,密码对了也一样" from app.auth import sessions sessions._failures.clear() uma = await auth_env.login("uma") assert (await uma.get("/api/auth/me")).status_code == 200 assert (await uma.post("/api/auth/logout")).status_code == 204 assert (await uma.get("/api/auth/me")).status_code == 401, "登出之后同一个 cookie 认不出来" uma = await auth_env.login("uma") async with SessionLocal() as s: await s.execute(update(AuthSession).values(last_seen_at=utcnow() - timedelta(hours=13))) await s.commit() assert (await uma.get("/api/auth/me")).status_code == 401, "闲置 12 小时过期" uma = await auth_env.login("uma") async with SessionLocal() as s: await s.execute(update(User).where(User.username == "uma").values(disabled=True)) await s.commit() assert (await uma.get("/api/auth/me")).status_code == 401, "停用立刻生效" async def test_last_admin_cannot_be_demoted(): admin = await auth_env.turn_on_local() users = (await admin.get("/api/users")).json()["items"] root = next(u for u in users if u["username"] == "root") r = await admin.patch(f"/api/users/{root['id']}", json={"role": "builder"}) assert r.status_code == 409 async def test_env_mode_wins_and_bootstrap(monkeypatch): monkeypatch.setenv("AGENTLAB_AUTH_MODE", "local") c = auth_env.client() assert (await c.get("/api/workflows")).status_code == 401 me = await c.get("/api/auth/me") assert me.status_code == 401 and me.json()["can_bootstrap"] is True, "新部署:登录页据此给「建第一个管理员」" r = await c.post("/api/auth/bootstrap", json={"username": "first", "password": "password-123"}) assert r.status_code == 200 and r.json()["role"] == "admin" me = await auth_env.client().get("/api/auth/me") assert me.status_code == 401 and me.json()["can_bootstrap"] is False, "有了管理员就不再给" assert (await c.post("/api/auth/bootstrap", json={"username": "second", "password": "password-123"}) ).status_code == 409 r = await c.put("/api/auth/settings", json={"mode": "off"}) assert r.status_code == 409, "环境变量指定的登录方式不能在设置里改" r = await c.put("/api/auth/settings", json={"user_can_chat": True}) assert r.status_code == 200 and r.json()["user_can_chat"] is True and r.json()["env_locked"] is True async def test_reset_admin_recovers_access(): from app.auth.__main__ import reset_admin await auth_env.turn_on_local() await auth_env.make_user("ops", "user", disabled=True) assert await reset_admin("ops", "brand-new-pass") == "已重置" ops = await auth_env.login("ops", "brand-new-pass") me = (await ops.get("/api/auth/me")).json() assert me["role"] == "admin" assert await reset_admin("fresh", "brand-new-pass") == "已新建" async with SessionLocal() as s: actions = list((await s.execute(select(AuditEvent.actor).where(AuditEvent.action == "user.reset_admin")) ).scalars()) assert actions == ["cli", "cli"] async def test_websocket_needs_a_login_and_the_route_judges_the_run(): """中间件只挡没登录的 WebSocket;登录了的交给路由按运行判(api/deps.py 的 run_scope,见 test_user_chat_access.py)。""" from app.auth.middleware import AuthMiddleware await auth_env.turn_on_local() await auth_env.make_user("uma", "user") uma = await auth_env.login("uma") reached: list[str] = [] async def inner(scope, receive, send): reached.append(scope["path"]) sent: list[dict] = [] async def receive(): return {"type": "websocket.connect"} async def send(message): sent.append(message) scope = {"type": "websocket", "path": "/api/runs/r1/stream", "headers": []} await AuthMiddleware(inner)(scope, receive, send) assert not reached and sent == [{"type": "websocket.close", "code": 1008}], "没登录的握手前就关掉" scope["headers"] = [(b"cookie", f"agentlab_session={uma.cookies.get('agentlab_session')}".encode())] await AuthMiddleware(inner)(scope, receive, send) assert reached == ["/api/runs/r1/stream"], "登录了的使用者交给路由按运行判,不再一刀切" await auth_env.make_user("bob", "builder") bob = await auth_env.login("bob") scope["headers"] = [(b"cookie", f"agentlab_session={bob.cookies.get('agentlab_session')}".encode())] await AuthMiddleware(inner)(scope, receive, send) assert reached == ["/api/runs/r1/stream"] * 2 # ---------------------------------------------------------------- oidc async def _oidc_login(c: httpx.AsyncClient, fake: FakeOidc, next_path: str = "/apps") -> httpx.Response: start = await c.get("/api/auth/oidc/start", params={"next": next_path}) assert start.status_code == 302, start.text async with httpx.AsyncClient(trust_env=False) as browser: granted = await browser.get(start.headers["location"]) assert granted.status_code == 302 callback = httpx.URL(granted.headers["location"]) return await c.get(callback.path, params=dict(callback.params)) async def test_oidc_login_maps_roles_and_syncs_groups(): with FakeOidc() as fake: c = auth_env.client() r = await c.put("/api/auth/settings", json={"mode": "oidc", "oidc": { "issuer": fake.issuer, "client_id": "agentlab", "client_secret": "s3cret"}}) assert r.status_code == 422, "没有管理员映射不能切到单点登录" r = await c.put("/api/auth/settings", json={"mode": "oidc", "oidc": { "issuer": fake.issuer, "client_id": "agentlab", "client_secret": "s3cret", "admin_groups": ["ops"], "builder_groups": ["analysts"]}}) assert r.status_code == 200, r.text assert r.json()["oidc"]["client_secret"] != "s3cret", "客户端密钥只回掩码" fake.next_claims = {"sub": "u-1", "preferred_username": "alice", "name": "Alice", "groups": ["ops", "finance"]} done = await _oidc_login(c, fake) assert done.status_code == 302 and done.headers["location"] == "/apps", done.headers.get("location") me = (await c.get("/api/auth/me")).json() assert (me["username"], me["role"], me["verified"]) == ("alice", "admin", True) assert set(me["groups"]) == {"ops", "finance"} groups = {g["name"]: g for g in (await c.get("/api/groups")).json()["items"]} assert groups["finance"]["source"] == "oidc" and groups["finance"]["members"] == ["alice"] r = await c.put(f"/api/groups/{groups['finance']['id']}/members", json={"members": []}) assert r.status_code == 409, "同步来的组只读" # 换一个人登录:只在 analysts 组,是搭建者;alice 不再在 finance 组时,下次登录成员关系跟着撤 other = auth_env.client() fake.next_claims = {"sub": "u-2", "preferred_username": "bert", "groups": ["analysts"]} await _oidc_login(other, fake) assert (await other.get("/api/auth/me")).json()["role"] == "builder" fake.next_claims = {"sub": "u-1", "preferred_username": "alice", "groups": ["ops"]} await _oidc_login(c, fake) async with SessionLocal() as s: finance = (await s.execute(select(Group).where(Group.name == "finance"))).scalar_one() assert (await c.get("/api/groups")).json()["items"] and \ next(g for g in (await c.get("/api/groups")).json()["items"] if g["id"] == finance.id)["members"] == [] async def test_oidc_state_cannot_be_replayed(): with FakeOidc() as fake: c = auth_env.client() r = await c.put("/api/auth/settings", json={"mode": "oidc", "oidc": { "issuer": fake.issuer, "client_id": "agentlab", "client_secret": "s3cret", "admin_users": ["alice"]}}) assert r.status_code == 200, r.text start = await c.get("/api/auth/oidc/start") async with httpx.AsyncClient(trust_env=False) as browser: granted = await browser.get(start.headers["location"]) callback = httpx.URL(granted.headers["location"]) params = dict(callback.params) first = await c.get(callback.path, params=params) assert first.headers["location"] == "/" c.cookies.clear() again = await c.get(callback.path, params=params) assert again.headers["location"].startswith("/login?error="), "state cookie 已作废,回放被拒" wrong = auth_env.client() await wrong.get("/api/auth/oidc/start") bad = await wrong.get(callback.path, params={**params, "state": "forged"}) assert bad.headers["location"].startswith("/login?error=") assert fake.token_calls == 1, "对不上的请求不去换 token"