modify: auth插件拆分,将token签发部分交给网关

This commit is contained in:
2026-09-10 16:43:17 +08:00
parent 93be99141f
commit 7cb5771e06
10 changed files with 467 additions and 37 deletions
+113
View File
@@ -0,0 +1,113 @@
"""
内置登录端到端测试(auth 域拆分验证)
验证拆分目标:POST /api/v1/auth/login 内置在网关,不依赖 auth 插件——
本测试只挂载内置 auth 路由(不加载任何插件),登录/签发/响应 shape 全部可用。
同时校验与拆分前 plugins.auth.login 的兼容性:
- 路径 /api/v1/auth/login、响应字段 {result, token, message, timestamp}
- 失败文案逐字一致(用户名错误 / 密码错误)
- token 可被 chongming_jwt 解密回读,claims 含 is_superuser
(网关 admin API 判超管的契约)
"""
import asyncio
import time
from pathlib import Path
import orjson
import pytest
from chongming_auth import open_database, seed_admin
from chongming_config import get_config
from chongming_jwt import JWT
from fastapi import FastAPI
from fastapi.testclient import TestClient
from chongming.app.routes.auth import router as auth_router
_SECURITY = get_config()["security"]
SECRET, ALGORITHM = _SECURITY["secret_key"], _SECURITY["algorithm"]
PREFIX = get_config()["default"]["prefix"]
@pytest.fixture()
def client(tmp_path: Path):
"""最小 app:仅内置 auth 路由 + 临时 auth 库(模拟 auth 插件完全缺席)。"""
async def _build():
db = await open_database({"data_dir": str(tmp_path)})
await seed_admin(db)
return db
db = asyncio.run(_build())
app = FastAPI()
app.include_router(auth_router)
app.state.auth_db = db
with TestClient(app) as c:
yield c
asyncio.run(db.dispose())
def test_login_success_shape_and_claims(client):
resp = client.post(
f"{PREFIX}/auth/login", json={"username": "admin", "password": "admin"}
)
assert resp.status_code == 200
data = resp.json()
# 响应 shape 与拆分前 gen_models 产物一致
assert set(data.keys()) == {"result", "token", "message", "timestamp"}
assert data["result"] == "success"
assert data["message"] == "登录成功"
assert isinstance(data["timestamp"], float)
assert data["timestamp"] == pytest.approx(time.time(), abs=60)
# token 可解密且 claims 契约完整(is_superuser 是 admin API 的判超管依据)
payload = orjson.loads(JWT(SECRET, ALGORITHM).decode(data["token"]))
assert payload["sub"]
assert payload["username"] == "admin"
assert payload["is_superuser"] is True
def test_login_failure_messages_compat(client):
"""失败文案与拆分前 plugins.auth.login 逐字一致。"""
for body, expected_msg in [
({"username": "nouser", "password": "x"}, "用户名错误"),
({"username": "admin", "password": "wrong"}, "密码错误"),
]:
resp = client.post(f"{PREFIX}/auth/login", json=body)
assert resp.status_code == 200
data = resp.json()
assert data["result"] == "fail"
assert data["token"] == ""
assert data["message"] == expected_msg
assert isinstance(data["timestamp"], float)
def test_login_works_without_auth_plugin(client):
"""拆分核心目标:无 auth 插件时登录照常(fixture 未加载任何插件)。"""
resp = client.post(
f"{PREFIX}/auth/login", json={"username": "admin", "password": "admin"}
)
assert resp.status_code == 200
assert resp.json()["result"] == "success"
def test_login_updates_last_login(client, tmp_path):
"""登录成功后 last_login_at 落库(顺带修复拆分前未 commit 的旧问题)。"""
client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "admin"})
row = asyncio.run(_read_admin_row(tmp_path))
assert row.last_login_at is not None
async def _read_admin_row(data_dir: Path):
from chongming_auth import Users
from sqlalchemy import select
db = await open_database({"data_dir": str(data_dir)})
try:
async with db.get_session() as sess:
return (
await sess.execute(select(Users).filter_by(username="admin"))
).scalar_one()
finally:
await db.dispose()
+215
View File
@@ -0,0 +1,215 @@
"""
auth_oauth 身份源插件测试(基于 chongming-auth 共享契约的第三方登录示例)
覆盖:
1. OTP 手机验证码登录全流程(演示模式 dev_code):
发码 → 错码拒绝 → 正确码登录签发平台 token → 同号复登复用同一账号(绑定生效)
2. GitHub OAuth 授权码流程(HTTP 调用打桩):
authorize 生成 URL+state → callback find-or-create 签发 token → state 一次性(重放拒绝)
3. 绑定表与共享 users 表落库验证(chongming_auth.Users 可查)
4. 插件导入 + 三方契约校验 0 错误
"""
import asyncio
import tempfile
from pathlib import Path
import orjson
import pytest
from chongming_auth import Users, open_database
from chongming_cache import Cache
from chongming_config import get_config
from chongming_jwt import JWT
from chongming_worker.contracts import validate_worker_contracts
# 与被测插件同源:issue_token 从 get_config()["security"] 读密钥,测试解码必须一致
_SECURITY = get_config()["security"]
SECRET, ALGORITHM = _SECURITY["secret_key"], _SECURITY["algorithm"]
@pytest.fixture()
def env(tmp_path: Path):
"""初始化插件运行环境:共享 auth 库 + 临时 sqlite 缓存 + global_var 注入。
注意:插件模块 @rpc_method 注册在进程内只执行一次,fixture 不清空注册表
(clear_registry 会导致后续测试 import no-op、注册表为空)。
"""
import plugins.auth_oauth # noqa: F401 触发 @rpc_method 注册(幂等)
import plugins.auth_oauth.global_var as gv
async def _build():
db = await open_database({"data_dir": str(tmp_path)})
cache = Cache(backend="sqlite", path=str(tmp_path / "cache.sqlite"))
return db, cache
db, cache = asyncio.run(_build())
gv.set_global_var("db", db)
gv.set_global_var("cache", cache)
gv.set_global_var("plugin_config", {
"return_otp_in_response": True,
"github": {
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"redirect_uri": "http://localhost:5173/auth/oauth/github/callback",
"scope": "read:user user:email",
},
})
yield gv
async def _teardown():
await db.dispose()
cache.close()
asyncio.run(_teardown())
def _decode(token: str) -> dict:
return orjson.loads(JWT(SECRET, ALGORITHM).decode(token))
# ---------- 手机验证码登录 ----------
def test_otp_full_flow(env):
from plugins.auth_oauth.router.phone import (
PhoneLoginPostRequest,
PhoneSendCodePostRequest,
phone_login,
phone_send_code,
)
phone = "13800138000"
# 1. 发码(演示模式返回 dev_code)
sent = asyncio.run(phone_send_code(PhoneSendCodePostRequest(phone=phone)))
assert sent.result == "success" and sent.dev_code
# 2. 错码拒绝
wrong = asyncio.run(phone_login(
PhoneLoginPostRequest(phone=phone, code="000000" if sent.dev_code != "000000" else "111111")
))
assert wrong.result == "fail" and wrong.token == ""
# 3. 正确码登录 → 平台 token
ok = asyncio.run(phone_login(
PhoneLoginPostRequest(phone=phone, code=sent.dev_code)
))
assert ok.result == "success" and ok.token
claims = _decode(ok.token)
assert claims["username"] == phone
assert claims["is_superuser"] is False
# 4. 验证码一次性
replay = asyncio.run(phone_login(
PhoneLoginPostRequest(phone=phone, code=sent.dev_code)
))
assert replay.result == "fail"
# 5. 复登(再次发码登录)→ 复用同一账号(绑定生效,不重复建号)
sent2 = asyncio.run(phone_send_code(PhoneSendCodePostRequest(phone=phone)))
ok2 = asyncio.run(phone_login(
PhoneLoginPostRequest(phone=phone, code=sent2.dev_code)
))
assert _decode(ok2.token)["sub"] == claims["sub"]
def test_otp_invalid_phone(env):
from plugins.auth_oauth.router.phone import (
PhoneSendCodePostRequest, phone_send_code,
)
resp = asyncio.run(phone_send_code(PhoneSendCodePostRequest(phone="abc")))
assert resp.result == "fail" and "手机号" in resp.message
# ---------- GitHub OAuth ----------
def test_github_authorize_and_callback(env, monkeypatch):
from plugins.auth_oauth.router.github import (
GithubCallbackPostRequest, github_authorize, github_callback,
)
from plugins.auth_oauth.providers import github as github_provider
# authorize:URL 含 client_id 与 state,state 已入缓存
authz = asyncio.run(github_authorize())
assert authz.result == "success"
assert "client_id=test-client-id" in authz.authorize_url
assert f"state={authz.state}" in authz.authorize_url
assert env.cache.get(f"auth_oauth:state:{authz.state}")
# 打桩:授权码换取与用户拉取(免外网)
fake_profile = {
"uid": "97594220", "username": "chongming-demo",
"email": "demo@example.com", "full_name": "Chongming Demo",
"avatar_url": "https://avatars.githubusercontent.com/u/97594220",
}
exchanged = {}
async def fake_exchange(client_id, client_secret, code, redirect_uri):
exchanged["code"] = code
return "fake-access-token"
async def fake_fetch(access_token):
assert access_token == "fake-access-token"
return dict(fake_profile)
monkeypatch.setattr(github_provider, "exchange_code", fake_exchange)
monkeypatch.setattr(github_provider, "fetch_user", fake_fetch)
resp = asyncio.run(github_callback(
GithubCallbackPostRequest(code="gh-code", state=authz.state)
))
assert resp.result == "success" and resp.token
claims = _decode(resp.token)
assert claims["username"] == "chongming-demo"
assert claims["email"] == "demo@example.com"
assert exchanged["code"] == "gh-code"
# state 一次性:重放拒绝
replay = asyncio.run(github_callback(
GithubCallbackPostRequest(code="gh-code", state=authz.state)
))
assert replay.result == "fail" and "state" in replay.message
# 落库验证:users 表有新账号 + 绑定指向同一账号;再次登录(新 state)复用
async def _read_users():
db = env.db
from sqlalchemy import select
async with db.get_session() as sess:
return (await sess.execute(select(Users))).scalars().all()
users = asyncio.run(_read_users())
assert len(users) == 1
assert users[0].email == "demo@example.com"
async def _second_login():
from plugins.auth_oauth.router.github import github_authorize as _a
a = await _a()
return await github_callback(
GithubCallbackPostRequest(code="gh-code-2", state=a.state)
)
second = asyncio.run(_second_login())
assert _decode(second.token)["sub"] == claims["sub"]
def test_github_callback_bad_state(env):
from plugins.auth_oauth.router.github import (
GithubCallbackPostRequest, github_callback,
)
resp = asyncio.run(github_callback(
GithubCallbackPostRequest(code="x", state="not-exist")
))
assert resp.result == "fail" and "state" in resp.message
# ---------- 契约校验 ----------
def test_contract_validation(env):
import plugins.auth_oauth # noqa: F401 确保注册
issues = validate_worker_contracts("auth_oauth")
errors = [i for i in issues if i.get("level") == "error"]
assert errors == [], f"契约校验存在错误: {errors}"
+11 -25
View File
@@ -1,37 +1,25 @@
"""
并发启动竞态测试:模拟 gunicorn 多 worker 同时启动时,
多个进程并发调用 plugins.auth.create_workers 初始化默认管理员。
多个进程并发执行网关内置的 admin 种子(chongming_auth.seed_admin)。
auth 域拆分后,admin 种子从 plugins.auth 的 on_init 迁移到网关
lifespan(setup_auth_store → chongming_auth.seed_admin),本测试随之改指向。
预期:不抛 IntegrityError,users 表中只有一条 admin 记录。
"""
import asyncio
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
import plugins.auth as auth_plugin
def fake_create_workers(session, config, count=1, worker_manager=None):
"""替换真实 worker 启动,避免依赖 zenoh 会话。与原始实现同为同步函数。"""
return []
from chongming_auth import open_database, seed_admin
async def main():
# 替换 Plugin 基类的 worker 创建实现,避免依赖 zenoh 会话
auth_plugin.plugin._create_workers_impl = fake_create_workers
with tempfile.TemporaryDirectory() as tmp:
config = {"type": "auth", "data_dir": tmp}
db = await open_database({"data_dir": tmp})
# 模拟 4 个 gunicorn worker 进程同时启动
results = await asyncio.gather(
auth_plugin.create_workers(None, config, 3),
auth_plugin.create_workers(None, config, 3),
auth_plugin.create_workers(None, config, 3),
auth_plugin.create_workers(None, config, 3),
*[seed_admin(db) for _ in range(4)],
return_exceptions=True,
)
@@ -42,15 +30,11 @@ async def main():
raise RuntimeError("并发启动存在未捕获异常")
# 校验 users 表中应只有一条 admin
from chongming_database import Database
from plugins.auth.models.auth import Users
from chongming_auth import Users
from sqlalchemy import select
db = Database(Path(tmp) / "auth.db")
await db.initialize()
async with db.get_session() as sess:
res = await sess.execute(select(Users))
rows = res.scalars().all()
rows = (await sess.execute(select(Users))).scalars().all()
print("✅ 4 个并发启动全部成功")
print(f"✅ users 表记录数: {len(rows)}(应为 1)")
@@ -58,6 +42,8 @@ async def main():
print(f" - {u.username} <{u.email}> superuser={u.is_superuser}")
assert len(rows) == 1, f"预期 1 条 admin,实际 {len(rows)} 条"
assert sum(1 for r in results if r is True) == 1, "应恰好一个并发方完成新建"
await db.dispose()
if __name__ == "__main__":