扫码登录: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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
// suggestDeviceName:UA 预填一个可读的默认设备名(与 /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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user