浏览器免重登: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:
@@ -34,6 +34,12 @@ export function logout()
|
||||
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
|
||||
// ——只是体验降级回宽限期路径,不影响登出本身。
|
||||
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
|
||||
// 浏览器端:删除服务端 session 并清除 HttpOnly cookie(cookie 自动随同源请求),
|
||||
// 否则下次开机 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_token;api.ts 在请求拿到 401 时惰性调用。返回是否成功。
|
||||
export async function refreshTokens(): Promise<boolean>
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return false; }
|
||||
|
||||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),故
|
||||
// 无需 store.refreshToken,直接无参调用。
|
||||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),
|
||||
// 直接无参调用。
|
||||
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 session(refresh_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;桌面端走
|
||||
// persistDesktopDeviceName(Go 持久化),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
@@ -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 WebView,wails:// / loopback origin)已是原生应用且 SW 行为不可靠,
|
||||
|
||||
@@ -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,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: "/" });
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user