This commit is contained in:
Baobhan Sith
2025-05-08 16:43:02 +08:00
parent 7513e98cdf
commit 787114bb4c
7 changed files with 74 additions and 7 deletions
@@ -921,3 +921,20 @@ export const getPublicCaptchaConfig = async (req: Request, res: Response): Promi
});
}
};
/**
* 检查系统中是否配置了任何 Passkey (GET /api/v1/auth/passkey/has-configured)
* 或者特定用户是否配置了 Passkey (GET /api/v1/auth/passkey/has-configured?username=xxx)
* 公开访问,用于登录页面判断是否显示 Passkey 登录按钮。
*/
export const checkHasPasskeys = async (req: Request, res: Response): Promise<void> => {
const username = req.query.username as string | undefined;
try {
const hasPasskeys = await passkeyService.hasPasskeysConfigured(username);
res.status(200).json({ hasPasskeys });
} catch (error: any) {
console.error(`[AuthController] 检查 Passkey 配置状态时出错 (username: ${username || 'any'}):`, error.message);
// 即使出错,也返回 false,避免登录流程中断
res.status(200).json({ hasPasskeys: false, error: '检查 Passkey 配置时出错。' });
}
};
+6 -1
View File
@@ -19,7 +19,8 @@ import {
// 新的 Passkey 管理处理器
listUserPasskeysHandler,
deleteUserPasskeyHandler,
updateUserPasskeyNameHandler // 新增:更新 Passkey 名称的处理器
updateUserPasskeyNameHandler, // 新增:更新 Passkey 名称的处理器
checkHasPasskeys // +++ 新增:检查是否有 Passkey 配置的处理器
} from './auth.controller';
import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
@@ -70,9 +71,13 @@ router.post('/passkey/register', isAuthenticated, verifyPasskeyRegistrationHandl
// POST /api/v1/auth/passkey/authentication-options - 生成 Passkey 认证选项 (公开或半公开,取决于是否提供了用户名)
router.post('/passkey/authentication-options', generatePasskeyAuthenticationOptionsHandler);
// POST /api/v1/auth/passkey/authenticate - 验证 Passkey 并登录用户 (公开)
router.post('/passkey/authenticate', ipBlacklistCheckMiddleware, verifyPasskeyAuthenticationHandler);
// GET /api/v1/auth/passkey/has-configured - 检查是否配置了 Passkey (公开)
router.get('/passkey/has-configured', checkHasPasskeys);
// --- User's Passkey Management Routes (New) ---
// GET /api/v1/auth/user/passkeys - 获取当前用户的所有 Passkey (需要认证)
router.get('/user/passkeys', isAuthenticated, listUserPasskeysHandler);
@@ -142,6 +142,13 @@ export class PasskeyRepository {
const { changes } = await runDb(db, sql, [name, credentialId]);
return changes > 0;
}
async getFirstPasskey(): Promise<Passkey | null> {
const db = await getDbInstance();
const sql = 'SELECT * FROM passkeys LIMIT 1';
const result = await getDb<any>(db, sql);
return mapPasskeyResult(result);
}
}
export const passkeyRepository = new PasskeyRepository();
@@ -301,6 +301,22 @@ export class PasskeyService {
}
await this.passkeyRepo.updatePasskeyName(credentialID, newName);
}
async hasPasskeysConfigured(username?: string): Promise<boolean> {
if (username) {
const user = await this.userRepo.findUserByUsername(username);
if (!user) {
return false; // 如果提供了用户名但用户不存在,则认为没有配置 passkey
}
const passkeys = await this.passkeyRepo.getPasskeysByUserId(user.id);
return passkeys.length > 0;
} else {
// 如果没有提供用户名,检查整个系统中是否存在任何 passkey
// 这对于“可发现凭证”场景可能有用,或者简单地检查系统是否启用了 passkey 功能
const anyPasskey = await this.passkeyRepo.getFirstPasskey();
return !!anyPasskey;
}
}
}
export const passkeyService = new PasskeyService(passkeyRepository, userRepository);
@@ -65,6 +65,7 @@ interface AuthState {
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
passkeys: PasskeyInfo[] | null; // NEW: Store for user's passkeys
passkeysLoading: boolean; // NEW: Loading state for passkeys
hasPasskeysAvailable: boolean; // NEW: Indicates if passkeys are available for login
}
export const useAuthStore = defineStore('auth', {
@@ -79,6 +80,7 @@ export const useAuthStore = defineStore('auth', {
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
passkeys: null, // Initialize passkeys as null
passkeysLoading: false, // Initialize passkeysLoading as false
hasPasskeysAvailable: false, // Initialize as false
}),
getters: {
// 可以添加一些 getter,例如获取用户名
@@ -505,6 +507,23 @@ export const useAuthStore = defineStore('auth', {
// 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
});
+8 -5
View File
@@ -10,7 +10,7 @@ import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
const { t } = useI18n();
const authStore = useAuthStore();
// loginRequires2FA
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, passkeys } = storeToRefs(authStore); // Get publicCaptchaConfig and passkeys
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore); // Get publicCaptchaConfig and hasPasskeysAvailable
//
const credentials = reactive({
@@ -92,10 +92,13 @@ const handleSubmit = async () => {
} // <-- Correctly closing the try block here
};
// Fetch CAPTCHA config on component mount
onMounted(() => {
// console.log('[LoginView] Component mounted, calling fetchCaptchaConfig...'); //
// Fetch CAPTCHA config and check passkey availability on component mount
onMounted(async () => {
// console.log('[LoginView] Component mounted, calling fetchCaptchaConfig and checkHasPasskeysConfigured...');
authStore.fetchCaptchaConfig();
// Check if passkeys are available for login (uses the new public endpoint)
// Optionally pass username if needed: await authStore.checkHasPasskeysConfigured(credentials.username);
await authStore.checkHasPasskeysConfigured();
});
// --- Passkey Login Handler ---
@@ -243,7 +246,7 @@ const handlePasskeyLogin = async () => {
</button>
<!-- Passkey Login Button -->
<div v-if="passkeys && passkeys.length > 0" class="mt-4 text-center">
<div v-if="hasPasskeysAvailable" class="mt-4 text-center">
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
class="w-full py-3 px-4 bg-secondary text-black border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 flex items-center justify-center">
<i class="fas fa-key mr-2"></i>
+1 -1
View File
@@ -76,7 +76,7 @@
<span class="text-xs text-text-tertiary ml-1">(ID: ...{{ typeof key.credentialID === 'string' && key.credentialID ? key.credentialID.slice(-8) : 'N/A' }})</span>
</span>
<div v-else class="flex items-center flex-grow">
<input type="text" v-model="editingPasskeyName" @keyup.enter="savePasskeyName(key.credentialID)" @keyup.esc="cancelEditPasskeyName" class="flex-grow max-w-sm px-2 py-1 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm" :placeholder="$t('settings.passkey.enterNamePlaceholder', '输入 Passkey 名称')" />
<input type="text" v-model="editingPasskeyName" @keyup.enter="savePasskeyName(key.credentialID)" @keyup.esc="cancelEditPasskeyName" class="flex-grow w-48 px-2 py-1 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm" :placeholder="$t('settings.passkey.enterNamePlaceholder', '输入 Passkey 名称')" />
<button @click="savePasskeyName(key.credentialID)" :disabled="passkeyEditLoadingStates[key.credentialID]" class="ml-2 px-2 py-1 bg-success text-success-text rounded-md text-xs font-medium hover:bg-success/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-success disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
{{ passkeyEditLoadingStates[key.credentialID] ? $t('common.saving') : $t('common.save') }}
</button>