feat: 实现 2FA (TOTP) 的设置、验证和禁用流程

This commit is contained in:
Baobhan Sith
2025-04-15 11:49:28 +08:00
parent ffb772546d
commit 171baec830
9 changed files with 1071 additions and 97 deletions
+36 -2
View File
@@ -15,7 +15,9 @@
"password": "Password",
"loginButton": "Login",
"loggingIn": "Logging in...",
"error": "Login failed. Please check your username and password."
"error": "Login failed. Please check your username and password.",
"twoFactorPrompt": "Enter your two-factor authentication code:",
"verifyButton": "Verify"
},
"connections": {
"title": "Connection Management",
@@ -291,9 +293,41 @@
"passwordsDoNotMatch": "New password and confirmation do not match.",
"generic": "Failed to change password. Please try again later."
}
},
"twoFactor": {
"title": "Two-Factor Authentication (TOTP)",
"status": {
"enabled": "Two-factor authentication is enabled.",
"disabled": "Two-factor authentication is currently disabled."
},
"enable": {
"button": "Enable Two-Factor Authentication"
},
"setup": {
"scanQrCode": "Scan the QR code below with your authenticator app:",
"orEnterSecret": "Or manually enter the secret key:",
"enterCode": "Enter the 6-digit code from your authenticator app:",
"verifyButton": "Verify & Activate"
},
"disable": {
"button": "Disable Two-Factor Authentication",
"passwordPrompt": "Enter your current password to confirm disabling:"
},
"success": {
"activated": "Two-factor authentication activated successfully!",
"disabled": "Two-factor authentication disabled successfully."
},
"error": {
"setupFailed": "Failed to get two-factor setup information.",
"codeRequired": "Please enter the verification code.",
"verificationFailed": "Invalid or expired verification code.",
"passwordRequiredForDisable": "Current password is required to disable.",
"disableFailed": "Failed to disable two-factor authentication."
}
}
},
"common": {
"loading": "Loading..."
"loading": "Loading...",
"cancel": "Cancel"
}
}
+36 -2
View File
@@ -15,7 +15,9 @@
"password": "密码",
"loginButton": "登录",
"loggingIn": "正在登录...",
"error": "登录失败,请检查用户名或密码。"
"error": "登录失败,请检查用户名或密码。",
"twoFactorPrompt": "请输入两步验证码:",
"verifyButton": "验证"
},
"connections": {
"title": "连接管理",
@@ -294,9 +296,41 @@
"passwordsDoNotMatch": "新密码和确认密码不匹配。",
"generic": "修改密码失败,请稍后重试。"
}
},
"twoFactor": {
"title": "两步验证 (TOTP)",
"status": {
"enabled": "两步验证已启用。",
"disabled": "两步验证当前未启用。"
},
"enable": {
"button": "启用两步验证"
},
"setup": {
"scanQrCode": "请使用您的 Authenticator 应用扫描下方的二维码:",
"orEnterSecret": "或者手动输入密钥:",
"enterCode": "请输入应用生成的 6 位验证码:",
"verifyButton": "验证并启用"
},
"disable": {
"button": "禁用两步验证",
"passwordPrompt": "请输入当前登录密码以确认禁用:"
},
"success": {
"activated": "两步验证已成功激活!",
"disabled": "两步验证已成功禁用。"
},
"error": {
"setupFailed": "获取两步验证设置信息失败。",
"codeRequired": "请输入验证码。",
"verificationFailed": "验证码无效或已过期。",
"passwordRequiredForDisable": "需要输入当前密码才能禁用。",
"disableFailed": "禁用两步验证失败。"
}
}
},
"common": {
"loading": "加载中..."
"loading": "加载中...",
"cancel": "取消"
}
}
+86 -14
View File
@@ -2,10 +2,11 @@ import { defineStore } from 'pinia';
import axios from 'axios';
import router from '../router'; // 引入 router 用于重定向
// 用户信息接口 (不含敏感信息)
// 扩展的用户信息接口,包含 2FA 状态
interface UserInfo {
id: number;
username: string;
isTwoFactorEnabled?: boolean; // 后端 /status 接口会返回这个
}
// Auth Store State 接口
@@ -14,6 +15,7 @@ interface AuthState {
user: UserInfo | null;
isLoading: boolean;
error: string | null;
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
}
export const useAuthStore = defineStore('auth', {
@@ -22,6 +24,7 @@ export const useAuthStore = defineStore('auth', {
user: null,
isLoading: false,
error: null,
loginRequires2FA: false, // 初始为不需要
}),
getters: {
// 可以添加一些 getter,例如获取用户名
@@ -32,32 +35,74 @@ export const useAuthStore = defineStore('auth', {
async login(credentials: { username: string; password: string }) {
this.isLoading = true;
this.error = null;
this.loginRequires2FA = false; // 重置 2FA 状态
try {
const response = await axios.post<{ message: string; user: UserInfo }>('/api/v1/auth/login', credentials);
// 登录成功
this.isAuthenticated = true;
this.user = response.data.user;
console.log('登录成功:', this.user);
// 登录成功后重定向到连接管理页面 (或仪表盘)
await router.push({ name: 'Connections' }); // 使用 await 确保导航完成
return true;
// 后端可能返回 user 或 requiresTwoFactor
const response = await axios.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/api/v1/auth/login', credentials);
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);
await router.push({ name: 'Connections' });
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 false;
return { success: false, error: this.error };
} finally {
this.isLoading = false;
}
},
// 登 Action (占位符)
async logout() {
// 登录时的 2FA 验证 Action
async verifyLogin2FA(token: string) {
if (!this.loginRequires2FA) {
throw new Error('当前登录流程不需要 2FA 验证。');
}
this.isLoading = true;
this.error = null;
try {
// TODO: 调用后端的登出 API (如果需要)
const response = await axios.post<{ message: string; user: UserInfo }>('/api/v1/auth/login/2fa', { token });
// 2FA 验证成功
this.isAuthenticated = true;
this.user = response.data.user;
this.loginRequires2FA = false; // 重置状态
console.log('2FA 验证成功,登录完成:', this.user);
await router.push({ name: 'Connections' });
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 {
// TODO: 调用后端的登出 API
// await axios.post('/api/v1/auth/logout');
// 清除本地状态
@@ -101,7 +146,34 @@ export const useAuthStore = defineStore('auth', {
// }
// }
// }
// 新增:检查并更新认证状态 Action
async checkAuthStatus() {
this.isLoading = true;
try {
const response = await axios.get<{ isAuthenticated: boolean; user: UserInfo }>('/api/v1/auth/status');
if (response.data.isAuthenticated && response.data.user) {
this.isAuthenticated = true;
this.user = response.data.user; // 更新用户信息,包含 isTwoFactorEnabled
this.loginRequires2FA = false; // 确保重置
console.log('认证状态已更新:', this.user);
} else {
this.isAuthenticated = false;
this.user = null;
this.loginRequires2FA = false;
}
} catch (error: any) {
// 如果获取状态失败 (例如 session 过期),则认为未认证
console.warn('检查认证状态失败:', error.response?.data?.message || error.message);
this.isAuthenticated = false;
this.user = null;
this.loginRequires2FA = false;
// 可选:如果不是 401 错误,可以记录更详细的日志
} finally {
this.isLoading = false;
}
},
// 修改密码 Action
async changePassword(currentPassword: string, newPassword: string) {
if (!this.isAuthenticated) {
+35 -19
View File
@@ -1,24 +1,32 @@
<script setup lang="ts">
import { reactive } from 'vue';
import { reactive, ref } from 'vue'; // 导入 ref
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; // 引入 useI18n
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth.store';
const { t } = useI18n(); // 获取 t 函数
const { t } = useI18n();
const authStore = useAuthStore();
const { isLoading, error } = storeToRefs(authStore); // 获取加载和错误状态
// 获取 loginRequires2FA 状态
const { isLoading, error, loginRequires2FA } = storeToRefs(authStore);
// 表单数据
const credentials = reactive({
username: '',
password: '',
});
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
// 处理登录提交
const handleLogin = async () => {
await authStore.login(credentials);
// 登录成功会自动重定向 (在 store action 中处理)
// 登录失败会在模板中显示错误信息
// 处理登录或 2FA 验证提交
const handleSubmit = async () => {
if (loginRequires2FA.value) {
// 如果需要 2FA,则调用 2FA 验证 action
await authStore.verifyLogin2FA(twoFactorToken.value);
} else {
// 否则,调用常规登录 action
await authStore.login(credentials);
}
// 成功后的重定向由 store action 处理
// 失败会更新 error 状态并在模板中显示
};
</script>
@@ -26,23 +34,31 @@ const handleLogin = async () => {
<div class="login-view">
<div class="login-form-container">
<h2>{{ t('login.title') }}</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">{{ t('login.username') }}:</label>
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading" />
<form @submit.prevent="handleSubmit">
<!-- 常规登录字段 -->
<div v-if="!loginRequires2FA">
<div class="form-group">
<label for="username">{{ t('login.username') }}:</label>
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading" />
</div>
<div class="form-group">
<label for="password">{{ t('login.password') }}:</label>
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
</div>
</div>
<div class="form-group">
<label for="password">{{ t('login.password') }}:</label>
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
<!-- 2FA 验证码输入 -->
<div v-if="loginRequires2FA" class="form-group">
<label for="twoFactorToken">{{ t('login.twoFactorPrompt') }}</label>
<input type="text" id="twoFactorToken" v-model="twoFactorToken" required :disabled="isLoading" pattern="\d{6}" title="请输入 6 位数字验证码" />
</div>
<div v-if="error" class="error-message">
<!-- 可以直接显示后端返回的错误或者映射到特定的 i18n key -->
{{ error }} <!-- 保持显示后端错误或者 t('login.error') -->
{{ error }}
</div>
<button type="submit" :disabled="isLoading">
{{ isLoading ? t('login.loggingIn') : t('login.loginButton') }}
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
</button>
</form>
</div>
+188 -16
View File
@@ -17,59 +17,211 @@
<label for="confirmPassword">{{ $t('settings.changePassword.confirmPassword') }}</label>
<input type="password" id="confirmPassword" v-model="confirmPassword" required>
</div>
<button type="submit" :disabled="loading">{{ loading ? $t('common.loading') : $t('settings.changePassword.submit') }}</button>
<p v-if="message" :class="{ 'success-message': isSuccess, 'error-message': !isSuccess }">{{ message }}</p>
<button type="submit" :disabled="changePasswordLoading">{{ changePasswordLoading ? $t('common.loading') : $t('settings.changePassword.submit') }}</button>
<p v-if="changePasswordMessage" :class="{ 'success-message': changePasswordSuccess, 'error-message': !changePasswordSuccess }">{{ changePasswordMessage }}</p>
</form>
</div>
<!-- 其他设置项可以在这里添加 -->
<hr>
<div class="settings-section">
<h2>{{ $t('settings.twoFactor.title') }}</h2>
<!-- 如果 2FA 已启用 -->
<div v-if="twoFactorEnabled">
<p class="success-message">{{ $t('settings.twoFactor.status.enabled') }}</p>
<form @submit.prevent="handleDisable2FA">
<div class="form-group">
<label for="disablePassword">{{ $t('settings.twoFactor.disable.passwordPrompt') }}</label>
<input type="password" id="disablePassword" v-model="disablePassword" required>
</div>
<button type="submit" :disabled="twoFactorLoading">{{ twoFactorLoading ? $t('common.loading') : $t('settings.twoFactor.disable.button') }}</button>
</form>
</div>
<!-- 如果 2FA 未启用 -->
<div v-else>
<p>{{ $t('settings.twoFactor.status.disabled') }}</p>
<!-- 如果不在设置流程中显示启用按钮 -->
<button v-if="!isSettingUp2FA" @click="handleSetup2FA" :disabled="twoFactorLoading">
{{ twoFactorLoading ? $t('common.loading') : $t('settings.twoFactor.enable.button') }}
</button>
<!-- 如果正在设置中 -->
<div v-if="isSettingUp2FA && setupData">
<p>{{ $t('settings.twoFactor.setup.scanQrCode') }}</p>
<img :src="setupData.qrCodeUrl" alt="QR Code">
<p>{{ $t('settings.twoFactor.setup.orEnterSecret') }} <code>{{ setupData.secret }}</code></p>
<form @submit.prevent="handleVerifyAndActivate2FA">
<div class="form-group">
<label for="verificationCode">{{ $t('settings.twoFactor.setup.enterCode') }}</label>
<input type="text" id="verificationCode" v-model="verificationCode" required pattern="\d{6}" title="请输入 6 位数字验证码">
</div>
<button type="submit" :disabled="twoFactorLoading">{{ twoFactorLoading ? $t('common.loading') : $t('settings.twoFactor.setup.verifyButton') }}</button>
<button type="button" @click="cancelSetup" :disabled="twoFactorLoading" style="margin-left: 10px;">{{ $t('common.cancel') }}</button>
</form>
</div>
</div>
<!-- 显示 2FA 操作的消息 -->
<p v-if="twoFactorMessage" :class="{ 'success-message': twoFactorSuccess, 'error-message': !twoFactorSuccess }">{{ twoFactorMessage }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted, computed } from 'vue'; // 导入 onMounted 和 computed
import { useAuthStore } from '../stores/auth.store';
import { useI18n } from 'vue-i18n';
import axios from 'axios'; // 需要 axios 来调用 API
const { t } = useI18n();
const authStore = useAuthStore();
// --- 修改密码状态 ---
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const message = ref('');
const isSuccess = ref(false);
const changePasswordLoading = ref(false);
const changePasswordMessage = ref('');
const changePasswordSuccess = ref(false);
// --- 2FA 状态 ---
const twoFactorEnabled = ref(false); // 用户当前的 2FA 状态
const twoFactorLoading = ref(false);
const twoFactorMessage = ref('');
const twoFactorSuccess = ref(false);
const setupData = ref<{ secret: string; qrCodeUrl: string } | null>(null); // 存储设置密钥和二维码
const verificationCode = ref(''); // 用户输入的验证码
const disablePassword = ref(''); // 禁用时需要输入的密码
// 计算属性判断当前是否处于 2FA 设置流程中
const isSettingUp2FA = computed(() => setupData.value !== null);
// 获取当前用户的 2FA 状态 (理想情况下后端应提供接口,这里暂时假设从 authStore 或其他地方获取)
const checkTwoFactorStatus = async () => {
// 调用 store action 获取最新状态
await authStore.checkAuthStatus();
// 从 store 更新本地状态
twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false;
};
onMounted(async () => { // 使 onMounted 异步
await checkTwoFactorStatus(); // 等待状态检查完成
});
const handleChangePassword = async () => {
message.value = ''; // 清除之前的消息
isSuccess.value = false;
changePasswordMessage.value = ''; // 清除之前的消息
changePasswordSuccess.value = false;
if (newPassword.value !== confirmPassword.value) {
message.value = t('settings.changePassword.error.passwordsDoNotMatch');
changePasswordMessage.value = t('settings.changePassword.error.passwordsDoNotMatch');
return;
}
// 可选:添加前端密码复杂度校验
// 可选:添加前端密码复杂度校验
loading.value = true;
changePasswordLoading.value = true;
try {
await authStore.changePassword(currentPassword.value, newPassword.value);
message.value = t('settings.changePassword.success');
isSuccess.value = true;
changePasswordMessage.value = t('settings.changePassword.success');
changePasswordSuccess.value = true;
// 清空表单
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
} catch (error: any) {
console.error('修改密码失败:', error);
message.value = error.message || t('settings.changePassword.error.generic');
isSuccess.value = false;
changePasswordMessage.value = error.message || t('settings.changePassword.error.generic');
changePasswordSuccess.value = false;
} finally {
loading.value = false;
changePasswordLoading.value = false;
}
};
// --- 2FA 相关方法 ---
// 开始设置 2FA
const handleSetup2FA = async () => {
twoFactorMessage.value = '';
twoFactorSuccess.value = false;
twoFactorLoading.value = true;
setupData.value = null; // 清除旧数据
verificationCode.value = ''; // 清除验证码
try {
const response = await axios.post<{ secret: string; qrCodeUrl: string }>('/api/v1/auth/2fa/setup');
setupData.value = response.data;
} catch (error: any) {
console.error('开始设置 2FA 失败:', error);
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.setupFailed');
} finally {
twoFactorLoading.value = false;
}
};
// 验证并激活 2FA
const handleVerifyAndActivate2FA = async () => {
if (!setupData.value || !verificationCode.value) {
twoFactorMessage.value = t('settings.twoFactor.error.codeRequired');
return;
}
twoFactorMessage.value = '';
twoFactorSuccess.value = false;
twoFactorLoading.value = true;
try {
await axios.post('/api/v1/auth/2fa/verify', { token: verificationCode.value });
twoFactorMessage.value = t('settings.twoFactor.success.activated');
twoFactorSuccess.value = true;
twoFactorEnabled.value = true; // 更新状态
setupData.value = null; // 清除设置数据
verificationCode.value = '';
} catch (error: any) {
console.error('验证并激活 2FA 失败:', error);
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.verificationFailed');
} finally {
twoFactorLoading.value = false;
}
};
// 禁用 2FA
const handleDisable2FA = async () => {
if (!disablePassword.value) {
twoFactorMessage.value = t('settings.twoFactor.error.passwordRequiredForDisable');
return;
}
twoFactorMessage.value = '';
twoFactorSuccess.value = false;
twoFactorLoading.value = true;
try {
await axios.delete('/api/v1/auth/2fa', { data: { password: disablePassword.value } }); // DELETE 请求体通过 data 发送
twoFactorMessage.value = t('settings.twoFactor.success.disabled');
twoFactorSuccess.value = true;
twoFactorEnabled.value = false; // 更新状态
disablePassword.value = ''; // 清空密码
} catch (error: any) {
console.error('禁用 2FA 失败:', error);
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.disableFailed');
} finally {
twoFactorLoading.value = false;
}
};
// 取消设置流程
const cancelSetup = () => {
setupData.value = null;
verificationCode.value = '';
twoFactorMessage.value = '';
};
</script>
<style scoped>
@@ -93,7 +245,8 @@ label {
margin-bottom: 5px;
}
input[type="password"] {
input[type="password"],
input[type="text"] {
width: 100%;
padding: 8px;
box-sizing: border-box;
@@ -109,6 +262,25 @@ button:disabled {
opacity: 0.6;
}
hr {
border: none;
border-top: 1px solid #eee;
margin: 30px 0;
}
code {
background-color: #f1f1f1;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
img {
display: block;
margin: 10px 0;
max-width: 200px; /* 限制二维码大小 */
}
.success-message {
color: green;
margin-top: 10px;