扫码登录:cdrop 自签会话原语 + 薄账户层 + 受限访客 + 2FA step-up

身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层:
自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。

后端 · 自签会话原语
- web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc
  会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类
- cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、
  shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分
- requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token
- /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP

后端 · 扫码登录(internal/httpapi/qr.go)
- qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper
- 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备
  (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准)
- 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL

后端 · 薄账户层与 2FA step-up
- accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key /
  显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联
- step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地
  换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制

前端(web/)
- 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell
  与聚珍排版管线、零新全局样式
- step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、
  独立 state key)后带 step_up_code/verifier 调 approve
- 三语 i18n qr.*;新增 qrcode 依赖

修复 · clipboard sweeper(早已提交的损坏)
- ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移
  bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行
  期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效)
- 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR
  转为正常清理(cleared count=1)

构建
- .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建
- Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络
  构建时传区域镜像即可,仓库不固化区域值)

文档
- 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example /
  compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim)

测试
- 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny /
  step-up 门)、extractIdentity、web_sessions 升级迁移
This commit is contained in:
2026-06-21 22:43:36 +08:00
parent eda23f79b1
commit 90a3790a98
50 changed files with 4130 additions and 43 deletions
+237
View File
@@ -0,0 +1,237 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, ArrowRight, RefreshCw, ScanLine } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { syncWebDeviceName } from "../features/auth/auth";
import { QrCanvas } from "../features/qr/QrCanvas";
import { persistDesktopDeviceName } from "../net/desktop";
import { pollQrStatus, qrStart, type QrStartResp, type QrStatus } from "../net/qr";
import { readInjectedDeviceType } from "../store/helpers";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { isAsciiDeviceName } from "../utils/format";
import { StateDot } from "../ui/glyphs";
import { Button, Callout, TextField } from "../ui/primitives";
import s from "./link_.new.module.css";
// 显码页:有人借了一台陌生设备,扫码把它接入自己的 cdrop。它本身公开(start /
// status 无需登录),故不设路由守卫——已登录用户也可经此把另一设备接入。
export const Route = createFileRoute("/link_/new")({
component: LinkNewPage,
});
type Step = "name" | "show";
function LinkNewPage()
{
const navigate = useNavigate();
const setAuth = useAppStore((s) => s.setAuth);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const [ step, setStep ] = useState<Step>("name");
const [ name, setName ] = useState(suggestDeviceName);
const [ session, setSession ] = useState<QrStartResp | null>(null);
const [ phase, setPhase ] = useState<QrStatus["status"]>("pending");
const [ error, setError ] = useState<string | null>(null);
const [ starting, setStarting ] = useState(false);
// 轮询的取消句柄。换二维码 / 卸载时调用即停。
const cancelPollRef = useRef<(() => void) | null>(null);
const onApproved = useCallback((st: QrStatus) =>
{
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
setAuth({
accessToken: st.accessToken,
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
});
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
const finalName = st.deviceName?.trim() || name.trim();
setSelfDeviceName(finalName);
persistDesktopDeviceName(finalName);
syncWebDeviceName(finalName);
navigate({ to: "/" });
}, [ name, navigate, setAuth, setSelfDeviceName ]);
const begin = useCallback(async (deviceName: string) =>
{
setError(null);
setStarting(true);
try
{
const resp = await qrStart({
deviceName,
deviceType: readInjectedDeviceType(),
});
setSession(resp);
setPhase("pending");
setStep("show");
}
catch (e)
{
setError(e instanceof Error ? e.message : String(e));
}
finally { setStarting(false); }
}, []);
// 拿到 session 即起长轮询;session 变化(重新生成)时换新轮询。
useEffect(() =>
{
if (!session) { return; }
cancelPollRef.current?.();
const cancel = pollQrStatus(session.requestId, session.pollSecret, {
onResolved: (st) =>
{
setPhase(st.status);
if (st.status === "approved") { onApproved(st); }
},
onError: () => { /* 单轮抖动:轮询循环自重试,UI 保持等待态 */ },
});
cancelPollRef.current = cancel;
return () => { cancel(); cancelPollRef.current = null; };
}, [ session, onApproved ]);
const handleContinue = () =>
{
const trimmed = name.trim();
if (!trimmed) { return; }
if (!isAsciiDeviceName(trimmed))
{
setError(t("settings.deviceName.asciiOnly"));
return;
}
void begin(trimmed);
};
const handleRegenerate = () =>
{
setError(null);
void begin(name.trim());
};
if (step === "name")
{
return (
<AuthShell
title={t("qr.show.title")}
subtitle={t("qr.show.subtitle")}
hideBrand
>
{error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
)}
<TextField
label={t("setup.field")}
hint={t("qr.show.nameHint")}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && name.trim())
{
e.preventDefault();
handleContinue();
}
}}
autoFocus
/>
<Button
variant="primary"
size="lg"
block
loading={starting}
rightIcon={<ArrowRight size={16} />}
onClick={handleContinue}
disabled={!name.trim()}
>
{t("qr.show.generate")}
</Button>
</AuthShell>
);
}
return (
<AuthShell title={t("qr.show.scanTitle")} hideBrand>
{error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
)}
<div className={s.stage}>
<div className={s.plate}>
{session && <QrCanvas value={session.qrPayload} />}
</div>
<StatusLine phase={phase} />
</div>
<ol className={s.steps} data-jz-level="paragraph">
<li>{t("qr.show.howto1")}</li>
<li>{t("qr.show.howto2")}</li>
</ol>
{(phase === "expired" || phase === "denied") && (
<Callout
tone={phase === "denied" ? "error" : "warn"}
icon={<AlertTriangle size={16} />}
>
{phase === "denied" ? t("qr.show.denied") : t("qr.show.expired")}
</Callout>
)}
{(phase === "expired" || phase === "denied") && (
<Button
variant="secondary"
size="lg"
block
leftIcon={<RefreshCw size={16} />}
onClick={handleRegenerate}
>
{t("qr.show.regenerate")}
</Button>
)}
</AuthShell>
);
}
// 显码页的等待态:一行平静的状态,pending 时 accent 脉冲点 + 「等待批准」。
function StatusLine(props: { phase: QrStatus["status"] })
{
const { phase } = props;
if (phase === "approved")
{
return (
<div className={s.statusLine}>
<StateDot tone="online" size={8} />
<span>{t("qr.show.approved")}</span>
</div>
);
}
if (phase === "expired" || phase === "denied") { return null; }
return (
<div className={s.statusLine}>
<span className={s.pulse} aria-hidden="true" />
<span className={s.waitText}>
<ScanLine size={14} />
{t("qr.show.waiting")}
</span>
</div>
);
}
// suggestDeviceNameUA 预填一个可读的默认设备名(与 /setup 同口径),可编辑。
function suggestDeviceName(): string
{
const ua = navigator.userAgent;
let browser = "Browser";
if (ua.includes("Firefox/")) { browser = "Firefox"; }
else if (ua.includes("Edg/")) { browser = "Edge"; }
else if (ua.includes("Chrome/")) { browser = "Chrome"; }
else if (ua.includes("Safari/")) { browser = "Safari"; }
let os = "Desktop";
if (ua.includes("Mac")) { os = "macOS"; }
else if (ua.includes("Windows")) { os = "Windows"; }
else if (ua.includes("Linux")) { os = "Linux"; }
return `${browser}-${os}-${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0")}`;
}