Files
Commilitia-Drop/web/src/net/api.ts
T
admin 018a77b13a 登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。

OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
  自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
  并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
  自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)

吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
  下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
  失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
  cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语

设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
  登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
  DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)

应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
  崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
  hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级

step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
  StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
  仅此次=guest),默认偏安全的「仅此次」

文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
  统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
2026-06-22 11:55:02 +08:00

112 lines
4.2 KiB
TypeScript
Raw 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.
import { useAppStore } from "../store";
import { refreshTokens, forceLogout } from "../features/auth/auth";
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
import { toAsciiDeviceName } from "../utils/format";
import { t } from "../i18n";
// /api 请求的来源前缀:桌面 Windows 注入 loopback 代理地址(绕开会缓冲 SSE 的
// custom scheme),macOS / 浏览器为空(同源相对 URL)。读一次注入值即定。
const API_BASE = readInjectedApiBase();
// 客户端类型(browser / macos / windows / linux),随每个请求发给后端登记设备。
const DEVICE_TYPE = readInjectedDeviceType();
function withBase(input: RequestInfo): RequestInfo
{
if (typeof input === "string" && API_BASE && input.startsWith("/"))
{
return API_BASE + input;
}
return input;
}
// Centralised fetch wrapper:
// - in dev, attaches Authorization: Bearer <VITE_CDROP_DEV_TOKEN> + X-Dev-User
// - in prod, attaches Authorization: Bearer <accessToken from store>
// - always sets X-Device-Name from the store
// - in prod, on a 401 response transparently refreshes the access token via
// /api/auth/refresh and retries once
// - in prod, ONLY if the server authoritatively confirms the session is gone
// (refresh → HTTP 401), forces a local logout so the app returns to login
//
// Returns the fetch Response untouched so callers can drive streaming bodies
// (SSE, relay GET stream) themselves.
//
// State is cleared ONLY on server-confirmed credential invalidation. Short-term
// failures never clear it: a transient refresh response (5xx / same-origin 403)
// yields "transient", and a network error during refresh THROWS out of here —
// both keep the session, leaving the original 401 for the caller as a retryable
// error. So a flaky network or a server blip can't log the user out; only a
// genuine revoke/expire (refresh → 401) does.
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
let r = await doFetch(input, init);
if (r.status === 401 && useAppStore.getState().authMode === "prod")
{
const outcome = await refreshTokens();
if (outcome === "refreshed")
{
r = await doFetch(input, init);
}
else if (outcome === "invalid")
{
// 服务端确证会话已失效(/auth/refresh 返回 401,已被吊销 / 过期)→
// 清空本地登录态,由 __root 守卫把界面送回登录页。返回这次 401 让调用
// 方照常收尾。"transient"5xx / 网络抖动)不进此分支:保留登录态。
forceLogout();
}
}
return r;
}
async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
const state = useAppStore.getState();
const headers = new Headers(init?.headers ?? {});
if (state.authMode === "dev")
{
const devToken = import.meta.env.VITE_CDROP_DEV_TOKEN;
if (!devToken)
{
throw new Error(t("errors.devTokenMissing"));
}
headers.set("Authorization", `Bearer ${devToken}`);
if (state.user?.id)
{
headers.set("X-Dev-User", state.user.id);
}
}
else
{
if (!state.accessToken)
{
throw new Error(t("errors.noAccessToken"));
}
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
if (state.selfDeviceName)
{
// Device names are ASCII-only; coerce here so a legacy non-ASCII name
// can't make Headers.set throw (the server sanitises identically).
const safeName = toAsciiDeviceName(state.selfDeviceName);
if (safeName) { headers.set("X-Device-Name", safeName); }
}
headers.set("X-Device-Type", DEVICE_TYPE);
return fetch(withBase(input), { ...init, headers });
}
export async function apiJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T>
{
const r = await apiFetch(input, init);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
if (r.status === 204) { return undefined as T; }
return ( await r.json() ) as T;
}