Files
AgentLab/backend/tests/test_auth_identity.py

356 lines
18 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.
"""身份三种模式(应用平台 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"