923ff63a3d
后端: - security.py: 新增 bcrypt 密码哈希/校验工具 - auth_service.py: 新增 login_with_password、reset_password_with_sms - auth.py: 新增 /login-password、/has-password、/set-password、/reset-password 接口 - schemas/auth.py: 新增 PasswordLoginRequest、SetPasswordRequest、ResetPasswordRequest、CheckPasswordResponse - crud/user.py: 新增 update_password 前端: - Login.tsx: 支持验证码/密码切换登录,密码模式下显示忘记密码入口 - Login.css: 新增登录方式切换标签、密码输入框样式 - authStore.ts: 新增 loginWithPassword - Settings.tsx: 新增账号安全区块,显示密码状态,打开设置/修改密码弹窗 - SetPasswordModal.tsx: 设置/修改密码弹窗(旧密码校验、密码显示切换、表单验证) - ResetPasswordModal.tsx: 忘记密码弹窗(手机号+验证码+新密码重置) 兼容: - 零数据库迁移,password_hash 字段已存在(nullable) - 现有接口不变,完全向后兼容旧版本
380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
/**
|
||
* Auth Store - Zustand + Tauri 原生文件存储
|
||
* ==========================================
|
||
*
|
||
* 认证状态持久化到 Tauri 原生文件存储
|
||
* 路径:应用配置目录 / auth.json
|
||
*
|
||
* 迁移:首次运行自动从 localStorage 迁移,迁移成功后删除旧数据
|
||
*/
|
||
|
||
import { create } from 'zustand';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import { getVersion } from '@tauri-apps/api/app';
|
||
import { client, clearAuthCache, PYTHON_API_BASE_URL, setOnTokenRefreshed, setOnAuthFailed } from '../api/client';
|
||
import { isTauri } from '../utils/env';
|
||
import { useVoiceStore } from './voiceStore';
|
||
import { useCoverAvatarStore } from './coverAvatarStore';
|
||
import { useProgressStore } from './progressStore';
|
||
import { useProjectStore } from './projectStore';
|
||
// 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>;
|
||
loginWithPassword: (_phone: string, _password: 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') {
|
||
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 }));
|
||
}
|
||
}
|
||
|
||
// 生成设备 ID(device_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;
|
||
};
|
||
|
||
// 缓存应用版本号,避免每次登录都调用 IPC
|
||
let appVersionCache: string | null = null;
|
||
|
||
async function getAppVersion(): Promise<string> {
|
||
if (appVersionCache) {
|
||
return appVersionCache;
|
||
}
|
||
try {
|
||
appVersionCache = await getVersion();
|
||
} catch {
|
||
appVersionCache = 'unknown';
|
||
}
|
||
return appVersionCache;
|
||
}
|
||
|
||
// 注册 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: await getAppVersion(),
|
||
});
|
||
|
||
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);
|
||
} catch (error) {
|
||
console.error('[authStore] 登录失败:', error);
|
||
isLoggingIn = false;
|
||
throw error;
|
||
} finally {
|
||
isLoggingIn = false;
|
||
}
|
||
},
|
||
|
||
// 手机号密码登录
|
||
loginWithPassword: async (phone: string, password: string) => {
|
||
if (isLoggingIn) {return;}
|
||
isLoggingIn = true;
|
||
|
||
try {
|
||
const data = await client.post<{ accessToken: string; refreshToken: string; user: UserInfo }>('/auth/login-password', {
|
||
mobile: phone,
|
||
password,
|
||
deviceId: generateDeviceId(),
|
||
deviceName: '美家卡智影桌面端',
|
||
osInfo: navigator.userAgent,
|
||
appVersion: await getAppVersion(),
|
||
});
|
||
|
||
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);
|
||
} catch (error) {
|
||
console.error('[authStore] 密码登录失败:', error);
|
||
isLoggingIn = false;
|
||
throw error;
|
||
} finally {
|
||
isLoggingIn = false;
|
||
}
|
||
},
|
||
|
||
closeKickModal: () => {
|
||
clearAuthState();
|
||
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' });
|
||
// 清除其他 Store 的内存状态,防止被踢后重新登录时数据残留
|
||
try {
|
||
useVoiceStore.getState().reset();
|
||
useCoverAvatarStore.getState().reset();
|
||
useProgressStore.getState().reset();
|
||
useProjectStore.getState().reset();
|
||
} catch (e) {
|
||
console.error('[Auth] 清除其他 Store 状态失败:', e);
|
||
}
|
||
},
|
||
|
||
logout: async () => {
|
||
// 断开 SSE 连接
|
||
disconnectSSE();
|
||
|
||
// 先调用后端登出 API(清理设备记录)
|
||
try {
|
||
await client.post('/auth/logout');
|
||
} catch {
|
||
// 后端登出失败不影响前端状态清理
|
||
}
|
||
|
||
// 重置状态
|
||
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);
|
||
}
|
||
|
||
// 清除其他 Store 的内存状态,防止切换账号后数据残留
|
||
try {
|
||
useVoiceStore.getState().reset();
|
||
useCoverAvatarStore.getState().reset();
|
||
useProgressStore.getState().reset();
|
||
useProjectStore.getState().reset();
|
||
} catch (e) {
|
||
console.error('[Auth] 清除其他 Store 状态失败:', e);
|
||
}
|
||
},
|
||
|
||
checkAuth: () => {
|
||
const { isAuthenticated, accessToken } = get();
|
||
return isAuthenticated && !!accessToken;
|
||
},
|
||
}));
|