Files
group_fqcd_jr/tools/login_console.py
T
lzf_0626 e29fce4012 新增登录测试台:一个记事本级别的前端,用来在浏览器里验证登录
tools/login_console.py —— 浏览器打开 http://127.0.0.1:8099 即可测。

与 chat_console.py 的关键差别:**它走真实登录接口**(POST /api/v1/auth/tokens)拿令牌,
而不是自己签。chat_console 是当初还没有登录接口时的权宜做法,这个测的是真链路。

页面能做的:
- 三个快捷填充按钮(客户 cust_t/123456、员工 risk_t/666666、管理员 admin_t/88888888);
- 登录后显示用户、角色标签、data_scope、令牌有效期(令牌只显示前 24 字符);
- 按角色列出可调的**只读**接口按钮(我的画像 / 风控概览 / 预警列表 / 角色清单 /
  管理视角看某人的身份),一眼核对"登录给的 roles"与"接口实际放行"是否一致;
- 把真实状态码与响应 JSON 原样摊在页面上,并写明 401 涵盖三种原因、429 是登录限流。

实现上不动 app/ 一个字节:内层用 create_app(),本进程只做两件事 —— 提供静态页、
把 /api/* 同源转发(httpx.ASGITransport 进程内调用,不起第二个服务)。同源转发避开 CORS,
也免得浏览器直连主服务;只监听 127.0.0.1,私钥始终留在服务端。

实测:页面 200;通过代理用 admin_t 登录拿到真实 JWT(sub=9003);错密码 401。

顺带说明一条边界变化:docs/24 当初拒绝"给底座加 dev token 端点",理由是那种端点等于把
**任意身份**开放给任何能访问服务的人。现在有了密码校验,浏览器拿令牌不再等于
"谁都能冒充任何人",那条顾虑已消除 —— 所以这个页面不是绕过安全设计,而是设计补齐后的正常用法。
2026-09-11 21:26:45 +08:00

268 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""登录测试台:一个记事本级别的前端,用来在浏览器里验证登录接口。
浏览器打开 **http://127.0.0.1:8099**(可用 `--port` 改)。
## 它做什么 / 不做什么
- **走真实登录接口**(`POST /api/v1/auth/tokens`)拿令牌,和前端将来要做的一模一样。
与 `tools/chat_console.py` 不同:那个是当初还没有登录接口时自己签令牌的权宜做法,
这个测的就是真链路。
- 拿到令牌后可以按角色调几个**只读**接口,直接把状态码与响应 JSON 摊在页面上 ——
方便确认"登录给的 roles 与接口实际放行的权限是否一致"。
- **不动 `app/` 一个字节**:内层用 `create_app()`,本进程只做两件事 —— 提供一个静态页面、
把 `/api/*` 同源转发给它。同源转发是为了避开 CORS,也让浏览器不必直连主服务。
- 只监听 `127.0.0.1`,不出本机。私钥始终在服务端(登录是后端做的),页面只拿到令牌。
## 为什么现在可以做这个了
`docs/24` 当初拒绝"给底座加 dev token 端点",理由是那种端点等于把**任意身份**开放给
任何能访问服务的人。现在不同了:登录接口要**校验密码**,所以浏览器拿令牌这件事
不再等于"谁都能冒充任何人"。这条顾虑已经消除。
用法:
python tools/login_console.py # 默认 8099
python tools/login_console.py --port 9000
"""
from __future__ import annotations
import argparse
import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, Response
from app.main import create_app
PAGE = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录测试台</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
max-width: 940px; margin: 0 auto; padding: 24px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { opacity: .65; margin-bottom: 20px; }
.card { border: 1px solid rgba(128,128,128,.35); border-radius: 10px;
padding: 16px; margin-bottom: 16px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
label { min-width: 62px; opacity: .8; }
input { flex: 1; min-width: 180px; padding: 7px 9px; border-radius: 6px;
border: 1px solid rgba(128,128,128,.5); background: transparent; color: inherit; }
button { padding: 7px 14px; border-radius: 6px; cursor: pointer;
border: 1px solid rgba(128,128,128,.5); background: rgba(128,128,128,.12);
color: inherit; font-size: 13px; }
button:hover { background: rgba(128,128,128,.22); }
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
button.primary:hover { background: #1d4ed8; }
pre { background: rgba(128,128,128,.12); padding: 12px; border-radius: 8px;
overflow: auto; max-height: 340px; font-size: 12.5px; margin: 8px 0 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
background: rgba(128,128,128,.18); margin-right: 6px; }
.ok { color: #16a34a; } .bad { color: #dc2626; }
.muted { opacity: .6; font-size: 12.5px; }
code { background: rgba(128,128,128,.18); padding: 1px 5px; border-radius: 4px; }
</style>
</head>
<body>
<h1>登录测试台</h1>
<div class="sub">
走真实接口 <code>POST /api/v1/auth/tokens</code>。密码仅用于本地演示。
</div>
<div class="card">
<div class="row">
<label>用户名</label><input id="username" placeholder="cust_t / risk_t / admin_t" autocomplete="off">
</div>
<div class="row">
<label>密码</label><input id="password" type="password" placeholder="演示口令">
</div>
<div class="row">
<button class="primary" onclick="doLogin()">登录</button>
<button onclick="logout()">登出(清令牌)</button>
<span class="muted">快捷填充:</span>
<button onclick="fill('cust_t','123456')">客户</button>
<button onclick="fill('risk_t','666666')">员工 / 风控</button>
<button onclick="fill('admin_t','88888888')">管理员</button>
</div>
</div>
<div class="card">
<div class="row"><strong>当前身份</strong></div>
<div id="identity" class="muted">未登录</div>
</div>
<div class="card">
<div class="row">
<strong>用这个令牌调接口</strong>
<span class="muted">(下面按钮按角色给,方便核对权限)</span>
</div>
<div class="row" id="probes"></div>
<pre id="output">(还没有请求)</pre>
</div>
<script>
let token = null;
function fill(u, p) {
document.getElementById('username').value = u;
document.getElementById('password').value = p;
}
function show(text) {
document.getElementById('output').textContent = text;
}
function renderIdentity(data) {
const el = document.getElementById('identity');
if (!data) { el.textContent = '未登录'; el.className = 'muted'; return; }
const roles = (data.roles || []).map(r => `<span class="tag">${r}</span>`).join('');
el.className = '';
el.innerHTML =
`用户 <b>${data.username || data.user_id}</b> 角色 ${roles || '<span class="muted">(无)</span>'}<br>` +
`<span class="muted">data_scope=<b>${data.data_scope ?? '-'}</b> ` +
`令牌有效期 ${data.expires_in} 秒 令牌 ${data.access_token.slice(0, 24)}…</span>`;
renderProbes(data.roles || []);
}
// 按角色给出可调的只读接口 —— 用来核对"登录给的 roles"与"接口实际放行"是否一致。
function renderProbes(roles) {
const probes = [
{ label: '查我的画像', path: '/api/v1/users/me/memory-profile', all: true },
{ label: '风控概览', path: '/api/v1/risk/overview', role: 'risk_operator' },
{ label: '预警列表', path: '/api/v1/risk/alerts?limit=5', role: 'risk_operator' },
{ label: '角色清单', path: '/api/v1/admin/roles', role: 'admin' },
{ label: '我的身份(管理视角)', path: '/api/v1/admin/users/9001/roles', role: 'admin' },
];
const box = document.getElementById('probes');
box.innerHTML = '';
probes.forEach(p => {
const allowed = p.all || roles.includes(p.role);
const b = document.createElement('button');
b.textContent = p.label;
b.disabled = !allowed;
if (!allowed) b.title = `需要角色 ${p.role}`;
b.onclick = () => callApi(p.path);
box.appendChild(b);
});
}
async function doLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) { show('请先填用户名与密码'); return; }
show('请求中…');
try {
const res = await fetch('/api/v1/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const body = await res.json();
if (res.ok) {
token = body.data.access_token;
renderIdentity({ ...body.data, username });
show(`HTTP ${res.status} 登录成功\\n\\n` + JSON.stringify(body, null, 2));
} else {
token = null;
renderIdentity(null);
show(`HTTP ${res.status} 登录失败\\n\\n` + JSON.stringify(body, null, 2) +
'\\n\\n提示:401 涵盖"用户名不存在 / 密码错 / 账号停用",服务端刻意不区分;' +
'429 是登录限流(60 秒 10 次)。');
}
} catch (e) {
show('请求异常:' + e);
}
}
async function callApi(path) {
if (!token) { show('请先登录'); return; }
show('请求中…');
try {
const res = await fetch(path, { headers: { Authorization: 'Bearer ' + token } });
let text = await res.text();
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 原样显示 */ }
const mark = res.ok ? '<span class="ok">通过</span>' : '<span class="bad">被拒</span>';
show(`GET ${path}\\nHTTP ${res.status} ${mark}\\n\\n` + text);
} catch (e) {
show('请求异常:' + e);
}
}
function logout() {
token = null;
renderIdentity(null);
show('已清除本地令牌(服务端没有注销接口,令牌在过期前仍然有效 —— 这是当前已知边界)');
}
</script>
</body>
</html>
"""
def build_console() -> FastAPI:
base_app = create_app()
console = FastAPI(title="登录测试台", docs_url=None, redoc_url=None)
@console.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
return HTMLResponse(PAGE)
@console.api_route(
"/api/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
)
async def proxy(path: str, request: Request) -> Response:
"""把 `/api/*` 同源转发给主应用(进程内 ASGI,不起第二个服务)。
同源是为了避开 CORS,也让浏览器不必直连主服务;`Authorization` 等请求头
原样透传。响应连状态码一起回传 —— 这个页面的用处之一就是看真实状态码。
"""
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in {"host", "content-length"}
}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=base_app),
base_url="http://base",
timeout=60,
) as client:
upstream = await client.request(
request.method,
f"/api/{path}",
headers=headers,
content=await request.body(),
params=request.query_params,
)
passthrough = {
key: value
for key, value in upstream.headers.items()
if key.lower() in {"content-type", "retry-after", "x-trace-id"}
}
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=passthrough,
)
return console
def main() -> None:
parser = argparse.ArgumentParser(description="登录测试台(浏览器验证登录接口)")
parser.add_argument("--port", type=int, default=8099)
args = parser.parse_args()
print(f"登录测试台:http://127.0.0.1:{args.port}")
print(" 演示账号:cust_t/123456 risk_t/666666 admin_t/88888888")
uvicorn.run(build_console(), host="127.0.0.1", port=args.port, log_level="warning")
if __name__ == "__main__":
main()