浏览器免重登: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
+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 ----