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