浏览器免重登:HttpOnly cookie + 服务端 session 表 + 设备名持久化

- 新增 web_sessions 表(sqlc):refresh_token 以 AES-256-GCM 落盘,密钥取自
  CDROP_SESSION_SECRET(env、不在库内);cookie 仅存不透明随机串,表 id 存其
  SHA-256,故 DB 单独泄露既换不出可用 cookie、也解不出 token
- /auth/exchange 建 session 并下发 HttpOnly; Secure; SameSite=Lax; Path=/api/auth
  cookie,响应体不再回传 refresh_token
- /auth/refresh 改为 cookie 驱动(7 天滑动失活),同时即开机静默免重登;
  新增 /auth/logout(删 session + 清 cookie)、/auth/device(写设备名入 session)
- device_name 随 session 存,开机 refresh 带回前端,抗 iOS PWA 存储清除;
  setup / 改名时 syncWebDeviceName 推送服务端
- striped per-session 锁串行化同会话并发 refresh,防一次性 refresh_token 被
  花两次而把用户从所有 tab 登出
- 前端 store 移除 refreshToken 字段(长寿命凭据彻底不进 JS);main.tsx 首屏前
  bootstrapAuth 用 cookie 静默续期,命中直接进已登录态、避免登录页闪现
- config:prod 强制 CDROP_SESSION_SECRET,缺失拒启动
- 桌面端不受影响(自有 loopback flow + Go keyring,不碰这两个端点)
This commit is contained in:
2026-06-16 00:54:40 +08:00
parent 8f550adbb1
commit e51666c52e
20 changed files with 874 additions and 81 deletions
+71 -23
View File
@@ -34,6 +34,12 @@ export function logout()
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
// ——只是体验降级回宽限期路径,不影响登出本身。
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
// 浏览器端:删除服务端 session 并清除 HttpOnly cookiecookie 自动随同源请求),
// 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。
if (!isDesktop() && useAppStore.getState().authMode === "prod")
{
void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
}
useAppStore.getState().clearAuth();
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
if (isDesktop()) { clearDesktopSession(); }
@@ -107,9 +113,10 @@ export async function loginProd(): Promise<void>
interface ExchangeResp
{
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
// refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚
// HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。
}
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
@@ -156,20 +163,42 @@ export async function completeOAuthLogin(code: string, state: string): Promise<v
useAppStore.getState().setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
});
}
// refreshTokens proxies to /api/auth/refresh. Returns true on success.
// api.ts calls this lazily when a request comes back 401.
interface CookieRefreshResp
{
access_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
device_name?: string;
}
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
// cookie 自动随同源请求发出(Path=/api/auth)。成功返回 {accessToken, user,
// deviceName}cookie 缺失 / 过期 / 被 IdP 拒绝 → null(调用方据此落到登录页)。
async function cookieRefresh(): Promise<{ accessToken: string; user: User; deviceName: string } | null>
{
const r = await fetch("/api/auth/refresh", { method: "POST" });
if (!r.ok) { return null; }
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
if (!data?.access_token || !data.user?.id) { return null; }
return {
accessToken: data.access_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
deviceName: data.device_name ?? "",
};
}
// refreshTokens 换新 access_tokenapi.ts 在请求拿到 401 时惰性调用。返回是否成功。
export async function refreshTokens(): Promise<boolean>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return false; }
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),
// 无需 store.refreshToken直接无参调用。
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),
// 直接无参调用。
if (isDesktop())
{
const res = await desktopRefresh();
@@ -180,26 +209,45 @@ export async function refreshTokens(): Promise<boolean>
return true;
}
const refreshToken = store.refreshToken;
if (!refreshToken) { return false; }
// 浏览器端:走 HttpOnly cookie sessionrefresh_token 在服务端,JS 不持有)。
const res = await cookieRefresh();
if (!res) { return false; }
const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user });
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
return true;
}
const r = await fetch("/api/auth/refresh", {
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token
// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名),
// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。
export async function bootstrapAuth(): Promise<void>
{
if (isDesktop()) { return; }
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
const res = await cookieRefresh();
if (!res) { return; }
const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user });
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
}
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
// persistDesktopDeviceNameGo 持久化),dev 无 session。失败静默——只是体验降级。
export function syncWebDeviceName(name: string): void
{
if (isDesktop()) { return; }
if (useAppStore.getState().authMode !== "prod") { return; }
void fetch("/api/auth/device", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!r.ok) { return false; }
const data = await r.json() as { access_token: string; refresh_token?: string };
if (!data.access_token) { return false; }
const cur = useAppStore.getState();
if (!cur.user) { return false; }
cur.setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
user: cur.user,
});
return true;
body: JSON.stringify({ device_name: name }),
}).catch(() => { /* ignore */ });
}
// ---- PKCE helpers ---------------------------------------------------------
+22 -9
View File
@@ -13,6 +13,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ToastViewport } from "./ui/feedback";
import { routeTree } from "./routeTree.gen";
import { useAppStore, type ThemeMode } from "./store";
import { bootstrapAuth } from "./features/auth/auth";
import { startCjkAutospace } from "./utils/cjkAutospace";
import { initDesktopMenuBridge, isDesktop, loadDesktopSettings } from "./net/desktop";
@@ -115,16 +116,28 @@ function App()
// 时从注入的 window.__CDROP_BOOT__ 同步水合(store/helpers.ts),无需异步等待。
applyTheme(useAppStore.getState().theme);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
function mount(): void
{
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
// CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续
// 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制
// 完会触发一次 runShim() 处理已挂载的子树。
requestAnimationFrame(() => { startCjkAutospace(); });
}
// CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续
// 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制
// 完会触发一次 runShim() 处理已挂载的子树。
requestAnimationFrame(() => { startCjkAutospace(); });
// 浏览器端「免重登」:首屏渲染前用 HttpOnly cookie 尝试静默续期。命中则直接以已
// 登录态挂载,避免登录页 / 设置页闪现;未命中(401)即照常落到登录页。桌面 / dev /
// sessionStorage 已有令牌时 bootstrapAuth 立即返回,几乎无延迟。4s 超时兜底:万一
// refresh 卡死,也不至于把整个应用永久挡在首屏外。
const BOOT_AUTH_TIMEOUT_MS = 4000;
void Promise.race([
bootstrapAuth().catch(() => { /* ignore:失败即落登录页 */ }),
new Promise<void>((resolve) => { window.setTimeout(resolve, BOOT_AUTH_TIMEOUT_MS); }),
]).finally(mount);
// PWA service worker:仅在浏览器 + prod 注册。dev 下会干扰 Vite HMR;桌面壳
// Wails WebViewwails:// / loopback origin)已是原生应用且 SW 行为不可靠,
+2 -1
View File
@@ -9,7 +9,7 @@ import {
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { LogOut, Trash2 } from "lucide-react";
import { logout } from "../features/auth/auth";
import { logout, syncWebDeviceName } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api";
@@ -96,6 +96,7 @@ function SettingsPage()
}
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
toast.ok(t("settings.deviceName.success"));
}
catch (e)
+2
View File
@@ -2,6 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Check, Monitor } from "lucide-react";
import { useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { syncWebDeviceName } from "../features/auth/auth";
import { persistDesktopDeviceName } from "../net/desktop";
import { useAppStore } from "../store";
import { t } from "../i18n";
@@ -37,6 +38,7 @@ function SetupPage()
}
setSelfDeviceName(trimmed);
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
navigate({ to: "/" });
};
+6 -9
View File
@@ -9,21 +9,19 @@ import {
export type AuthSlice = Pick<
AppState,
"authMode" | "accessToken" | "refreshToken" | "user" | "setAuth" | "clearAuth"
"authMode" | "accessToken" | "user" | "setAuth" | "clearAuth"
>;
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
// 跨重启的持久化由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责
// 因为 wails:// scheme 下 WebView 存储不存活。sessionStorage 仅用于浏览器同 tab
// 刷新;桌面在单次运行内的连续性由内存中的 store 保证。
// refreshToken 在 JS 里恒为 null:浏览器只在内存持有(不落盘),桌面则完全不持有
// refresh 走 Go 内部,refresh_token 只在 Go 进程)——见凭据策略。
// 跨重启的持久化:桌面由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责
// wails:// scheme 下 WebView 存储不存活);浏览器由服务端 HttpOnly session cookie
// 负责(开机 bootstrapAuth 静默续期,见 features/auth/auth.ts)。sessionStorage 仅
// 用于浏览器同 tab 刷新存活。refresh_token 永不进 JS——只在服务端 / Go 进程。
export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set) =>
({
authMode: initialAuthMode,
accessToken: readSessionAccess(),
refreshToken: null,
user: readSessionUser(),
setAuth: (a) =>
@@ -35,7 +33,6 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
}
set({
accessToken: a.accessToken,
refreshToken: a.refreshToken ?? null,
user: a.user,
});
},
@@ -47,6 +44,6 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
window.sessionStorage.removeItem(USER_KEY);
}
set({ accessToken: null, refreshToken: null, user: null });
set({ accessToken: null, user: null });
},
});
+4 -4
View File
@@ -2,8 +2,9 @@ import type { Locale } from "../i18n";
// Auth shape — see FRONTEND_DESIGN.md §6.
// access_token lives in sessionStorage so a tab refresh keeps the user logged in
// (PROJECT_BRIEF.md §2). refresh_token stays in the store only — never in storage —
// to keep XSS exposure as small as possible.
// (PROJECT_BRIEF.md §2). The refresh_token never touches JS at all: in the
// browser it lives server-side behind an HttpOnly session cookie (see
// features/auth/auth.ts), on desktop inside the Go process (OS keyring).
export interface User
{
id: string;
@@ -116,9 +117,8 @@ export interface AppState
// ---- auth slice ----
authMode: AuthMode;
accessToken: string | null;
refreshToken: string | null;
user: User | null;
setAuth: (a: { accessToken: string; refreshToken?: string; user: User }) => void;
setAuth: (a: { accessToken: string; user: User }) => void;
clearAuth: () => void;
// ---- device slice ----