Files
meijiaka-zy/tauri-app/src/store/authStore.ts
T
小鱼开发 95e55293c6 security: 全面生产安全加固与部署修复
后端安全:
- DEBUG 默认 True → False
- 彻底移除 AUTH_BYPASS 认证绕过
- 验证码不再明文打印到日志
- 上传接口增加大小限制(500MB/20MB/100MB)与魔数校验
- python-jose → PyJWT, 更新 requirements.lock/uv.lock
- Bandit 恢复关键规则(B104/B301/B305/B314/B324/B603/B607)
- 修复 5 处 try_except_pass, 15 处加 nosec 注释
- 启用 Bandit pre-commit 钩子

前端安全:
- 配置完整 CSP 策略
- 收紧 Capabilities(fs:allow-read-file → $RESOURCE/**)
- 移除硬编码 devToken
- 清理前端 TODO(美家卡智影命名统一)

部署修复:
- docker-compose.prod 增加 alembic 迁移步骤
- api + scheduler 增加 Redis 心跳健康检查
- Nginx 添加安全响应头
- Nginx client_max_body_size 100M → 500M
- .env.example 补充 UPLOAD_MAX_* 配置与安全注释

其他:
- /voice/upload 合并到 /upload/audio
- Rust 上传增加文件大小检查
- 清理 Rust 19 处 println! + 前端 21 处 console.info
- 修复 VideoCompose.tsx toast 未导入(已有bug)
2026-05-10 23:31:34 +08:00

300 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Auth Store - Zustand + Tauri 原生文件存储
* ==========================================
*
* 认证状态持久化到 Tauri 原生文件存储
* 路径:应用配置目录 / auth.json
*
* 迁移:首次运行自动从 localStorage 迁移,迁移成功后删除旧数据
*/
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { client, clearAuthCache, PYTHON_API_BASE_URL, setOnTokenRefreshed, setOnAuthFailed } from '../api/client';
import { isTauri } from '../utils/env';
// uiStore 不再直接导入,弹窗由 React 组件通过状态驱动渲染
interface UserInfo {
id: string;
nickname: string;
avatar: string;
}
interface AuthState {
isAuthenticated: boolean;
accessToken: string | null;
refreshToken: string | null;
user: UserInfo | null;
isLoading: boolean;
showKickModal: boolean;
kickMessage: string;
}
interface AuthActions {
login: (_phone: string, _code: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => boolean;
loadFromStorage: () => Promise<void>;
closeKickModal: () => void;
}
type AuthStore = AuthState & AuthActions;
const initialState: AuthState = {
isAuthenticated: false,
accessToken: null,
refreshToken: null,
user: null,
isLoading: true,
showKickModal: false,
kickMessage: '',
};
const AUTH_LOCAL_STORAGE_KEY = 'ai-video-auth';
// 登录防重入标志
let isLoggingIn = false;
// SSE 连接(模块级,不放入 Zustand state,因为 EventSource 不可序列化)
let _sseConnection: EventSource | null = null;
function connectSSE(accessToken: string) {
disconnectSSE();
if (!accessToken || !PYTHON_API_BASE_URL) {return;}
const url = `${PYTHON_API_BASE_URL}/events?token=${encodeURIComponent(accessToken)}`;
const es = new EventSource(url);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'kick') {
console.warn('[SSE] 收到踢人消息:', data);
const state = useAuthStore.getState();
if (!state.isAuthenticated || state.showKickModal) return;
// 先弹窗,不清除状态;用户点击确认后再清除
useAuthStore.setState({
showKickModal: true,
kickMessage: '当前账号在其他设备登录,即将退出',
});
}
} catch (e) {
console.error('[SSE] 消息解析失败:', e);
}
};
es.onerror = () => {
console.error('[SSE] 连接错误,3秒后尝试重连');
es.close();
_sseConnection = null;
setTimeout(() => {
const { accessToken: currentToken } = useAuthStore.getState();
if (currentToken) {connectSSE(currentToken);}
}, 3000);
};
_sseConnection = es;
}
function disconnectSSE() {
if (_sseConnection) {
_sseConnection.close();
_sseConnection = null;
}
}
/**
* 清理本地认证状态(被踢或 Token 失效时调用,不通知后端)
*/
async function clearAuthState(): Promise<void> {
disconnectSSE();
clearAuthCache();
if (isTauri()) {
await invoke('clear_auth_state').catch(() => {});
} else {
localStorage.removeItem(AUTH_LOCAL_STORAGE_KEY);
}
}
/**
* 保存认证状态(Tauri 文件存储优先,浏览器回退 localStorage
*/
async function saveAuthState(state: AuthState): Promise<void> {
const payload = {
isAuthenticated: state.isAuthenticated,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
};
if (isTauri()) {
await invoke('save_auth_state', { state: payload });
} else {
localStorage.setItem(AUTH_LOCAL_STORAGE_KEY, JSON.stringify({ state: payload }));
}
}
// 生成设备 IDdevice_id 保持在 localStorage,因为它不需要太大空间
const generateDeviceId = () => {
const stored = localStorage.getItem('device_id');
if (stored) {
return stored;
}
const newId = `web_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('device_id', newId);
return newId;
};
// 注册 Token 刷新回调:client.ts 的 doRefreshToken 成功后会触发此回调,
// 确保新 Token 被持久化到 Tauri 文件存储 / localStorage
setOnTokenRefreshed((tokens) => {
const state = useAuthStore.getState();
if (state.isAuthenticated) {
const newState: AuthState = {
...state,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
useAuthStore.setState(newState);
saveAuthState(newState).catch((e) => console.error('[authStore] 保存刷新后的 Token 失败:', e));
}
});
// 注册认证失效回调:Refresh Token 失效(401 设备已失效)时先弹窗
// 注意:不能调用 logout(),因为 logout() 内部会请求 /auth/logout,又可能触发 401 循环
setOnAuthFailed(() => {
const state = useAuthStore.getState();
if (!state.isAuthenticated || state.showKickModal) return;
// 先弹窗,不清除状态;用户点击确认后再清除
useAuthStore.setState({
showKickModal: true,
kickMessage: '当前账号在其他设备登录,即将退出',
});
});
export const useAuthStore = create<AuthStore>((set, get) => ({
...initialState,
/**
* 从存储加载认证状态(Tauri 文件存储优先,浏览器回退 localStorage
*/
loadFromStorage: async () => {
set({ isLoading: true });
try {
if (!isTauri()) {
// 浏览器环境:回退到 localStorage
const data = localStorage.getItem(AUTH_LOCAL_STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
const state = parsed.state as AuthState | undefined;
if (state?.isAuthenticated && state.accessToken) {
set({ ...initialState, ...state, isLoading: false });
// 浏览器环境重连 SSE
connectSSE(state.accessToken);
return;
}
}
set({ ...initialState, isLoading: false });
return;
}
// Tauri 环境:从原生文件加载
const result = await invoke<{ code: number; data?: AuthState }>('load_auth_state');
if (result.code === 200 && result.data) {
const loadedState: AuthState = {
...initialState,
isAuthenticated: result.data.isAuthenticated ?? false,
accessToken: result.data.accessToken ?? null,
refreshToken: result.data.refreshToken ?? null,
user: result.data.user ?? null,
isLoading: false,
};
set(loadedState);
clearAuthCache();
// 若已登录,重连 SSE 以接收单设备踢人通知
if (loadedState.isAuthenticated && loadedState.accessToken) {
connectSSE(loadedState.accessToken);
}
} else {
set({ ...initialState, isLoading: false });
}
} catch (e) {
console.error('[authStore] 加载认证状态失败:', e);
set({ ...initialState, isLoading: false });
}
},
// 手机号验证码登录
login: async (phone: string, code: string) => {
if (isLoggingIn) {return;}
isLoggingIn = true;
try {
const data = await client.post<{ accessToken: string; refreshToken: string; user: UserInfo }>('/auth/login', {
mobile: phone,
code,
deviceId: generateDeviceId(),
deviceName: '美家卡智影桌面端',
osInfo: navigator.userAgent,
appVersion: '0.1.0',
});
const newState = {
isAuthenticated: true,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user,
isLoading: false,
showKickModal: false,
kickMessage: '',
};
set(newState);
await saveAuthState(newState);
clearAuthCache();
connectSSE(data.accessToken);
} finally {
isLoggingIn = false;
}
},
closeKickModal: () => {
clearAuthState();
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' });
},
logout: async () => {
// 断开 SSE 连接
disconnectSSE();
// 先调用后端登出 API(清理设备记录)
try {
await client.post('/auth/logout');
} catch (e) {
// 后端登出失败不影响前端状态清理
console.warn('[Auth] 后端登出调用失败:', e);
}
// 重置状态
set({ ...initialState, isLoading: false });
// 清除内存缓存
clearAuthCache();
// 清除存储中的认证数据
try {
if (isTauri()) {
await invoke('clear_auth_state');
} else {
localStorage.removeItem(AUTH_LOCAL_STORAGE_KEY);
}
} catch (e) {
console.error('[Auth] 清除认证状态失败:', e);
}
},
checkAuth: () => {
const { isAuthenticated, accessToken } = get();
return isAuthenticated && !!accessToken;
},
}));