525 lines
24 KiB
TypeScript
525 lines
24 KiB
TypeScript
import { defineStore } from 'pinia';
|
|
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
|
import router from '../router'; // 引入 router 用于重定向
|
|
import { setLocale } from '../i18n'; // 导入 setLocale
|
|
|
|
// 扩展的用户信息接口,包含 2FA 状态和语言偏好
|
|
interface UserInfo {
|
|
id: number;
|
|
username: string;
|
|
isTwoFactorEnabled?: boolean; // 后端 /status 接口会返回这个
|
|
language?: 'en' | 'zh'; // 用户偏好语言
|
|
}
|
|
|
|
// Passkey Information Interface
|
|
export interface PasskeyInfo { // + Export 接口
|
|
credentialID: string;
|
|
publicKey: string; // Or a more specific type if available
|
|
counter: number;
|
|
transports?: AuthenticatorTransport[]; // e.g., "usb", "nfc", "ble", "internal"
|
|
creationDate: string; // ISO date string
|
|
lastUsedDate: string; // ISO date string
|
|
name?: string; // User-friendly name for the passkey
|
|
// Add other relevant fields from your backend response
|
|
}
|
|
|
|
// 登录请求的载荷接口
|
|
interface LoginPayload {
|
|
username: string;
|
|
password: string;
|
|
rememberMe?: boolean; // 可选的“记住我”标志
|
|
}
|
|
|
|
// Public CAPTCHA Config Interface (mirrors backend public config)
|
|
interface PublicCaptchaConfig {
|
|
enabled: boolean;
|
|
provider: 'hcaptcha' | 'recaptcha' | 'none';
|
|
hcaptchaSiteKey?: string;
|
|
recaptchaSiteKey?: string;
|
|
}
|
|
|
|
// Backend's full CAPTCHA Settings Interface (as returned by /settings/captcha)
|
|
interface FullCaptchaSettings {
|
|
enabled: boolean;
|
|
provider: 'hcaptcha' | 'recaptcha' | 'none';
|
|
hcaptchaSiteKey?: string;
|
|
hcaptchaSecretKey?: string; // We won't use this in authStore
|
|
recaptchaSiteKey?: string;
|
|
recaptchaSecretKey?: string; // We won't use this in authStore
|
|
}
|
|
|
|
|
|
// Auth Store State 接口
|
|
interface AuthState {
|
|
isAuthenticated: boolean;
|
|
user: UserInfo | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
|
|
// 存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持)
|
|
ipBlacklist: {
|
|
entries: any[]; // TODO: Define a proper type for blacklist entries
|
|
total: number;
|
|
};
|
|
needsSetup: boolean; // 是否需要初始设置
|
|
publicCaptchaConfig: PublicCaptchaConfig | null; // Public CAPTCHA config
|
|
passkeys: PasskeyInfo[] | null; // Store for user's passkeys
|
|
passkeysLoading: boolean; // Loading state for passkeys
|
|
hasPasskeysAvailable: boolean; // Indicates if passkeys are available for login
|
|
}
|
|
|
|
export const useAuthStore = defineStore('auth', {
|
|
state: (): AuthState => ({
|
|
isAuthenticated: false, // 初始为未登录
|
|
user: null,
|
|
isLoading: false,
|
|
error: null,
|
|
loginRequires2FA: false, // 初始为不需要
|
|
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
|
|
needsSetup: false, // 初始假设不需要设置
|
|
publicCaptchaConfig: null, // Initialize CAPTCHA config as null
|
|
passkeys: null, // Initialize passkeys as null
|
|
passkeysLoading: false, // Initialize passkeysLoading as false
|
|
hasPasskeysAvailable: false, // Initialize as false
|
|
}),
|
|
getters: {
|
|
// 可以添加一些 getter,例如获取用户名
|
|
loggedInUser: (state) => state.user?.username,
|
|
},
|
|
actions: {
|
|
// 清除错误状态
|
|
clearError() {
|
|
this.error = null;
|
|
},
|
|
// 设置错误状态
|
|
setError(errorMessage: string) {
|
|
this.error = errorMessage;
|
|
},
|
|
|
|
// 登录 Action - 更新为接受 LoginPayload + optional captchaToken
|
|
async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
this.loginRequires2FA = false; // 重置 2FA 状态
|
|
try {
|
|
// 后端可能返回 user 或 requiresTwoFactor
|
|
// 将完整的 payload (包含 rememberMe 和 captchaToken) 发送给后端
|
|
const response = await apiClient.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/auth/login', payload); // 使用 apiClient
|
|
|
|
if (response.data.requiresTwoFactor) {
|
|
// 需要 2FA 验证
|
|
console.log('登录需要 2FA 验证');
|
|
this.loginRequires2FA = true;
|
|
// 不设置 isAuthenticated 和 user,等待 2FA 验证
|
|
return { requiresTwoFactor: true }; // 返回特殊状态给调用者
|
|
} else if (response.data.user) {
|
|
// 登录成功 (无 2FA)
|
|
this.isAuthenticated = true;
|
|
this.user = response.data.user;
|
|
console.log('登录成功 (无 2FA):', this.user);
|
|
// 设置语言
|
|
if (this.user?.language) {
|
|
setLocale(this.user.language);
|
|
}
|
|
// await router.push({ name: 'Workspace' }); // 改为页面刷新
|
|
window.location.href = '/'; // 跳转到根路径并刷新
|
|
return { success: true };
|
|
} else {
|
|
// 不应该发生,但作为防御性编程
|
|
throw new Error('登录响应无效');
|
|
}
|
|
} catch (err: any) {
|
|
console.error('登录失败:', err);
|
|
this.isAuthenticated = false;
|
|
this.user = null;
|
|
this.loginRequires2FA = false;
|
|
this.error = err.response?.data?.message || err.message || '登录时发生未知错误。';
|
|
return { success: false, error: this.error };
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// 登录时的 2FA 验证 Action
|
|
async verifyLogin2FA(token: string) {
|
|
if (!this.loginRequires2FA) {
|
|
throw new Error('当前登录流程不需要 2FA 验证。');
|
|
}
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
try {
|
|
const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/login/2fa', { token }); // 使用 apiClient
|
|
// 2FA 验证成功
|
|
this.isAuthenticated = true;
|
|
this.user = response.data.user;
|
|
this.loginRequires2FA = false; // 重置状态
|
|
console.log('2FA 验证成功,登录完成:', this.user);
|
|
// 设置语言
|
|
if (this.user?.language) {
|
|
setLocale(this.user.language);
|
|
}
|
|
// await router.push({ name: 'Workspace' }); // 改为页面刷新
|
|
window.location.href = '/'; // 跳转到根路径并刷新
|
|
return { success: true };
|
|
} catch (err: any) {
|
|
console.error('2FA 验证失败:', err);
|
|
// 不清除 isAuthenticated 或 user,因为用户可能只是输错了验证码
|
|
this.error = err.response?.data?.message || err.message || '2FA 验证时发生未知错误。';
|
|
return { success: false, error: this.error };
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
|
|
// 登出 Action
|
|
async logout() {
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
this.loginRequires2FA = false; // 重置 2FA 状态
|
|
try {
|
|
// 调用后端的登出 API
|
|
await apiClient.post('/auth/logout'); // 使用 apiClient
|
|
|
|
// 清除本地状态
|
|
this.isAuthenticated = false;
|
|
this.user = null;
|
|
// Removed passkeys clear on logout
|
|
console.log('已登出');
|
|
// 登出后重定向到登录页
|
|
await router.push({ name: 'Login' });
|
|
} catch (err: any) {
|
|
console.error('登出失败:', err);
|
|
this.error = err.response?.data?.message || err.message || '登出时发生未知错误。';
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// 检查并更新认证状态 Action
|
|
async checkAuthStatus() {
|
|
this.isLoading = true;
|
|
try {
|
|
const response = await apiClient.get<{ isAuthenticated: boolean; user: UserInfo }>('/auth/status'); // 使用 apiClient
|
|
if (response.data.isAuthenticated && response.data.user) {
|
|
this.isAuthenticated = true;
|
|
this.user = response.data.user; // 更新用户信息,包含 isTwoFactorEnabled 和 language
|
|
this.loginRequires2FA = false; // 确保重置
|
|
console.log('认证状态已更新:', this.user);
|
|
// 设置语言
|
|
if (this.user?.language) {
|
|
setLocale(this.user.language);
|
|
}
|
|
} else {
|
|
this.isAuthenticated = false;
|
|
this.user = null;
|
|
this.loginRequires2FA = false;
|
|
// Removed passkeys clear on unauthenticated
|
|
}
|
|
} catch (error: any) {
|
|
// 如果获取状态失败 (例如 session 过期),则认为未认证
|
|
console.warn('检查认证状态失败:', error.response?.data?.message || error.message);
|
|
this.isAuthenticated = false;
|
|
this.user = null;
|
|
this.loginRequires2FA = false;
|
|
// Removed passkeys clear on error
|
|
// 可选:如果不是 401 错误,可以记录更详细的日志
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// 修改密码 Action
|
|
async changePassword(currentPassword: string, newPassword: string) {
|
|
if (!this.isAuthenticated) {
|
|
throw new Error('用户未登录,无法修改密码。');
|
|
}
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
try {
|
|
const response = await apiClient.put<{ message: string }>('/auth/password', { // 使用 apiClient
|
|
currentPassword,
|
|
newPassword,
|
|
});
|
|
console.log('密码修改成功:', response.data.message);
|
|
// 密码修改成功后,通常不需要更新本地状态,但可以清除错误
|
|
return true;
|
|
} catch (err: any) {
|
|
console.error('修改密码失败:', err);
|
|
this.error = err.response?.data?.message || err.message || '修改密码时发生未知错误。';
|
|
// 抛出错误,以便组件可以捕获并显示 (提供默认消息以防 this.error 为 null)
|
|
throw new Error(this.error ?? '修改密码时发生未知错误。');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// --- IP 黑名单管理 Actions ---
|
|
/**
|
|
* 获取 IP 黑名单列表
|
|
* @param limit 每页数量
|
|
* @param offset 偏移量
|
|
*/
|
|
async fetchIpBlacklist(limit: number = 50, offset: number = 0) {
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
try {
|
|
const response = await apiClient.get('/settings/ip-blacklist', { // 使用 apiClient
|
|
params: { limit, offset }
|
|
});
|
|
// 更新本地状态
|
|
this.ipBlacklist.entries = response.data.entries;
|
|
this.ipBlacklist.total = response.data.total;
|
|
console.log('获取 IP 黑名单成功:', response.data);
|
|
return response.data; // { entries: [], total: number }
|
|
} catch (err: any) {
|
|
console.error('获取 IP 黑名单失败:', err);
|
|
this.error = err.response?.data?.message || err.message || '获取 IP 黑名单时发生未知错误。';
|
|
// 确保抛出 Error 时提供字符串消息
|
|
throw new Error(this.error ?? '获取 IP 黑名单时发生未知错误。');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 从 IP 黑名单中删除一个 IP
|
|
* @param ip 要删除的 IP 地址
|
|
*/
|
|
async deleteIpFromBlacklist(ip: string) {
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
try {
|
|
await apiClient.delete(`/settings/ip-blacklist/${encodeURIComponent(ip)}`); // 使用 apiClient
|
|
console.log(`IP ${ip} 已从黑名单删除`);
|
|
// 从本地 state 中移除 (或者重新获取列表)
|
|
this.ipBlacklist.entries = this.ipBlacklist.entries.filter(entry => entry.ip !== ip);
|
|
this.ipBlacklist.total = Math.max(0, this.ipBlacklist.total - 1);
|
|
return true;
|
|
} catch (err: any) {
|
|
console.error(`删除 IP ${ip} 失败:`, err);
|
|
this.error = err.response?.data?.message || err.message || '删除 IP 时发生未知错误。';
|
|
// 确保抛出 Error 时提供字符串消息
|
|
throw new Error(this.error ?? '删除 IP 时发生未知错误。');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// 检查是否需要初始设置
|
|
async checkSetupStatus() {
|
|
// 不需要设置 isLoading,这个检查应该在后台快速完成
|
|
try {
|
|
const response = await apiClient.get<{ needsSetup: boolean }>('/auth/needs-setup'); // 使用 apiClient
|
|
this.needsSetup = response.data.needsSetup;
|
|
console.log(`[AuthStore] Needs setup status: ${this.needsSetup}`);
|
|
return this.needsSetup; // 返回状态给调用者
|
|
} catch (error: any) {
|
|
console.error('检查设置状态失败:', error.response?.data?.message || error.message);
|
|
// 如果检查失败,保守起见假设不需要设置,以避免卡在设置页面
|
|
this.needsSetup = false;
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// 获取公共 CAPTCHA 配置 (修改为从 /settings/captcha 获取)
|
|
async fetchCaptchaConfig() {
|
|
console.log('[AuthStore] fetchCaptchaConfig called. Forcing refetch.'); // 更新日志,表明强制刷新
|
|
|
|
// Don't set isLoading for this, it should be quick background fetch
|
|
try {
|
|
console.log('[AuthStore] Fetching CAPTCHA config from /settings/captcha...');
|
|
// 修改 API 端点
|
|
const response = await apiClient.get<FullCaptchaSettings>('/settings/captcha');
|
|
const fullConfig = response.data;
|
|
|
|
// 从完整配置中提取公共部分
|
|
this.publicCaptchaConfig = {
|
|
enabled: fullConfig.enabled,
|
|
provider: fullConfig.provider,
|
|
hcaptchaSiteKey: fullConfig.hcaptchaSiteKey,
|
|
recaptchaSiteKey: fullConfig.recaptchaSiteKey,
|
|
};
|
|
|
|
console.log('[AuthStore] Public CAPTCHA config derived from /settings/captcha:', this.publicCaptchaConfig);
|
|
} catch (error: any) {
|
|
console.error('获取 CAPTCHA 配置失败 (from /settings/captcha):', error.response?.data?.message || error.message);
|
|
// Set a default disabled config on error to prevent blocking login UI
|
|
this.publicCaptchaConfig = {
|
|
enabled: false,
|
|
provider: 'none',
|
|
};
|
|
}
|
|
},
|
|
|
|
// --- Passkey Actions ---
|
|
async loginWithPasskey(username: string, assertionResponse: any) {
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
this.loginRequires2FA = false; // Passkey login bypasses traditional 2FA
|
|
try {
|
|
const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/passkey/authenticate', {
|
|
username,
|
|
assertionResponse,
|
|
});
|
|
|
|
this.isAuthenticated = true;
|
|
this.user = response.data.user;
|
|
console.log('Passkey 登录成功:', this.user);
|
|
if (this.user?.language) {
|
|
setLocale(this.user.language);
|
|
}
|
|
window.location.href = '/'; // 跳转到根路径并刷新
|
|
return { success: true };
|
|
|
|
} catch (err: any) {
|
|
console.error('Passkey 登录失败:', err);
|
|
this.isAuthenticated = false;
|
|
this.user = null;
|
|
this.error = err.response?.data?.message || err.message || 'Passkey 登录时发生未知错误。';
|
|
return { success: false, error: this.error };
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
async getPasskeyRegistrationOptions(username: string) {
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
try {
|
|
const response = await apiClient.post('/auth/passkey/registration-options', { username });
|
|
return response.data; // Returns FIDO2 creation options
|
|
} catch (err: any) {
|
|
console.error('获取 Passkey 注册选项失败:', err);
|
|
this.error = err.response?.data?.message || err.message || '获取 Passkey 注册选项失败。';
|
|
throw new Error(this.error ?? '获取 Passkey 注册选项失败。');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
async registerPasskey(username: string, registrationResponse: any) {
|
|
this.isLoading = true;
|
|
this.error = null;
|
|
try {
|
|
await apiClient.post('/auth/passkey/register', {
|
|
username,
|
|
registrationResponse,
|
|
});
|
|
console.log('Passkey 注册成功');
|
|
// Optionally, refresh user data or passkeys list if applicable
|
|
return { success: true };
|
|
} catch (err: any) {
|
|
console.error('Passkey 注册失败:', err);
|
|
this.error = err.response?.data?.message || err.message || 'Passkey 注册失败。';
|
|
throw new Error(this.error ?? 'Passkey 注册失败。');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// Action to fetch user's passkeys
|
|
async fetchPasskeys() {
|
|
if (!this.isAuthenticated) {
|
|
console.warn('User not authenticated. Cannot fetch passkeys.');
|
|
this.passkeys = null;
|
|
return;
|
|
}
|
|
this.passkeysLoading = true;
|
|
this.error = null; // Clear previous errors
|
|
try {
|
|
// Define an interface for the backend response structure
|
|
interface BackendPasskeyInfo {
|
|
credential_id: string;
|
|
public_key: string;
|
|
counter: number;
|
|
transports?: AuthenticatorTransport[];
|
|
created_at: string; // Backend uses snake_case
|
|
last_used_at: string; // Backend uses snake_case
|
|
name?: string;
|
|
}
|
|
const response = await apiClient.get<BackendPasskeyInfo[]>('/auth/user/passkeys');
|
|
// Map backend response to frontend PasskeyInfo structure
|
|
this.passkeys = response.data.map(pk => ({
|
|
credentialID: pk.credential_id,
|
|
publicKey: pk.public_key,
|
|
counter: pk.counter,
|
|
transports: pk.transports,
|
|
creationDate: pk.created_at, // Map created_at to creationDate
|
|
lastUsedDate: pk.last_used_at, // Map last_used_at to lastUsedDate
|
|
name: pk.name,
|
|
}));
|
|
console.log('Passkeys fetched and mapped successfully:', this.passkeys);
|
|
} catch (err: any) {
|
|
console.error('Failed to fetch passkeys:', err);
|
|
this.error = err.response?.data?.message || err.message || 'Failed to load passkeys.';
|
|
this.passkeys = null; // Clear passkeys on error
|
|
} finally {
|
|
this.passkeysLoading = false;
|
|
}
|
|
},
|
|
|
|
// Action to delete a passkey
|
|
async deletePasskey(credentialID: string) {
|
|
if (!this.isAuthenticated) {
|
|
throw new Error('User not authenticated. Cannot delete passkey.');
|
|
}
|
|
this.isLoading = true; // Use general isLoading or a specific one for this action
|
|
this.error = null;
|
|
try {
|
|
await apiClient.delete(`/auth/user/passkeys/${credentialID}`);
|
|
console.log(`Passkey ${credentialID} deleted successfully.`);
|
|
// Refresh the passkey list
|
|
await this.fetchPasskeys();
|
|
return { success: true };
|
|
} catch (err: any) {
|
|
console.error(`Failed to delete passkey ${credentialID}:`, err);
|
|
this.error = err.response?.data?.message || err.message || 'Failed to delete passkey.';
|
|
throw new Error(this.error ?? 'Failed to delete passkey.');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
// Action to update a passkey's name
|
|
async updatePasskeyName(credentialID: string, newName: string) {
|
|
if (!this.isAuthenticated) {
|
|
throw new Error('User not authenticated. Cannot update passkey name.');
|
|
}
|
|
// Consider using a specific loading state for this if needed, e.g., this.passkeyNameUpdateLoading = true;
|
|
this.error = null;
|
|
try {
|
|
await apiClient.put(`/auth/user/passkeys/${credentialID}/name`, { name: newName });
|
|
console.log(`Passkey ${credentialID} name updated to "${newName}".`);
|
|
// Refresh the passkey list to show the new name
|
|
await this.fetchPasskeys();
|
|
return { success: true };
|
|
} catch (err: any) {
|
|
console.error(`Failed to update passkey ${credentialID} name:`, err);
|
|
this.error = err.response?.data?.message || err.message || 'Failed to update passkey name.';
|
|
throw new Error(this.error ?? 'Failed to update passkey name.');
|
|
} finally {
|
|
// if using specific loading state: this.passkeyNameUpdateLoading = false;
|
|
}
|
|
},
|
|
|
|
// Action to check if passkeys are configured (for login page)
|
|
async checkHasPasskeysConfigured(username?: string) {
|
|
// This action should not set isLoading to true, as it's a quick check
|
|
// and primarily used to determine UI elements on the login page.
|
|
try {
|
|
const params = username ? { username } : {};
|
|
const response = await apiClient.get<{ hasPasskeys: boolean }>('/auth/passkey/has-configured', { params });
|
|
this.hasPasskeysAvailable = response.data.hasPasskeys;
|
|
console.log(`[AuthStore] Passkeys available for ${username || 'any user'}: ${this.hasPasskeysAvailable}`);
|
|
return this.hasPasskeysAvailable;
|
|
} catch (error: any) {
|
|
console.error('Failed to check if passkeys are configured:', error.response?.data?.message || error.message);
|
|
this.hasPasskeysAvailable = false; // Default to false on error
|
|
return false;
|
|
}
|
|
},
|
|
},
|
|
persist: true, // Revert to simple persistence to fix TS error for now
|
|
});
|