2026-05-10 04:46:14 +03:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
/**
|
|
|
|
|
|
* TwoFactorCard — управление 2FA: включить (3-step wizard) / отключить.
|
|
|
|
|
|
* Sprint 4 Phase B/2 — split SecurityTab (audit O-refactor-04 хвост).
|
|
|
|
|
|
*
|
|
|
|
|
|
* 2FA flow по ТЗ §1.6/Прил. Г.4.2 (TOTP 6 цифр, recovery 8 шт.):
|
|
|
|
|
|
* - Включить → POST /api/2fa/init → QR + secret → user сканирует →
|
|
|
|
|
|
* ввод 6-значного кода → POST /api/2fa/confirm → показ 8 recovery-codes один раз.
|
|
|
|
|
|
* - Отключить → modal с password → POST /api/2fa/disable.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Регенерация recovery-codes вынесена в RecoveryCodesCard.vue.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import * as authApi from '../../../api/auth';
|
|
|
|
|
|
import { useAuthStore } from '../../../stores/auth';
|
|
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
|
|
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const has2fa = computed(() => auth.user?.totp_enabled ?? false);
|
|
|
|
|
|
|
|
|
|
|
|
// === Setup wizard (3 шага: init → confirm → show codes) ===
|
|
|
|
|
|
const setupOpen = ref(false);
|
|
|
|
|
|
const setupStep = ref<'init' | 'confirm' | 'codes'>('init');
|
|
|
|
|
|
const setupSecret = ref('');
|
|
|
|
|
|
const setupQrUrl = ref('');
|
|
|
|
|
|
const setupCode = ref('');
|
|
|
|
|
|
const setupRecoveryCodes = ref<string[]>([]);
|
|
|
|
|
|
const setupError = ref('');
|
|
|
|
|
|
const setupBusy = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function openSetup(): Promise<void> {
|
|
|
|
|
|
setupOpen.value = true;
|
|
|
|
|
|
setupStep.value = 'init';
|
|
|
|
|
|
setupError.value = '';
|
|
|
|
|
|
setupCode.value = '';
|
|
|
|
|
|
setupRecoveryCodes.value = [];
|
|
|
|
|
|
setupBusy.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await authApi.twoFactorInit();
|
|
|
|
|
|
setupSecret.value = r.secret;
|
|
|
|
|
|
setupQrUrl.value = r.qr_url;
|
|
|
|
|
|
setupStep.value = 'confirm';
|
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
|
setupError.value = error instanceof Error ? error.message : 'Ошибка инициализации.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setupBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmSetup(): Promise<void> {
|
|
|
|
|
|
setupError.value = '';
|
|
|
|
|
|
setupBusy.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await authApi.twoFactorConfirm(setupCode.value);
|
|
|
|
|
|
setupRecoveryCodes.value = r.recovery_codes;
|
|
|
|
|
|
setupStep.value = 'codes';
|
|
|
|
|
|
if (auth.user) auth.user = { ...auth.user, totp_enabled: true };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setupError.value = 'Неверный код. Проверьте время на устройстве.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setupBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeSetup(): void {
|
|
|
|
|
|
setupOpen.value = false;
|
|
|
|
|
|
setupSecret.value = '';
|
|
|
|
|
|
setupQrUrl.value = '';
|
|
|
|
|
|
setupCode.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === Disable dialog ===
|
|
|
|
|
|
const disableOpen = ref(false);
|
|
|
|
|
|
const disablePassword = ref('');
|
|
|
|
|
|
const disableError = ref('');
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmDisable(): Promise<void> {
|
|
|
|
|
|
disableError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await authApi.twoFactorDisable(disablePassword.value);
|
|
|
|
|
|
if (auth.user) auth.user = { ...auth.user, totp_enabled: false };
|
|
|
|
|
|
disableOpen.value = false;
|
|
|
|
|
|
disablePassword.value = '';
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
disableError.value = 'Неверный пароль.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<v-card variant="outlined" class="pa-4 mb-4" data-testid="2fa-card">
|
|
|
|
|
|
<div class="d-flex justify-space-between align-center mb-3">
|
|
|
|
|
|
<h3 class="text-subtitle-2 ma-0">Двухфакторная авторизация (2FA)</h3>
|
|
|
|
|
|
<v-chip v-if="has2fa" color="success" size="small" variant="tonal">включена</v-chip>
|
|
|
|
|
|
<v-chip v-else color="warning" size="small" variant="tonal">выключена</v-chip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
|
|
|
|
<template v-if="has2fa">
|
|
|
|
|
|
Используется TOTP (Google Authenticator / Yandex Key). 8 одноразовых резервных кодов сохранены в
|
|
|
|
|
|
безопасном месте.
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
2026-05-12 20:24:33 +03:00
|
|
|
|
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key, 1Password
|
|
|
|
|
|
и другие TOTP-приложения.
|
2026-05-10 04:46:14 +03:00
|
|
|
|
</template>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="d-flex ga-2 flex-wrap">
|
|
|
|
|
|
<v-btn
|
|
|
|
|
|
v-if="!has2fa"
|
|
|
|
|
|
color="primary"
|
|
|
|
|
|
variant="flat"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
prepend-icon="mdi-shield-key"
|
|
|
|
|
|
data-testid="enable-2fa-btn"
|
|
|
|
|
|
@click="openSetup"
|
|
|
|
|
|
>
|
|
|
|
|
|
Включить 2FA
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
<v-btn
|
|
|
|
|
|
v-else
|
|
|
|
|
|
variant="outlined"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
color="error"
|
|
|
|
|
|
prepend-icon="mdi-shield-off"
|
|
|
|
|
|
data-testid="disable-2fa-btn"
|
|
|
|
|
|
@click="disableOpen = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
Отключить 2FA
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 2FA SETUP WIZARD -->
|
|
|
|
|
|
<v-dialog v-model="setupOpen" :max-width="480" data-testid="setup-dialog">
|
|
|
|
|
|
<v-card>
|
|
|
|
|
|
<v-card-title>Включение 2FA</v-card-title>
|
|
|
|
|
|
<v-card-text>
|
|
|
|
|
|
<template v-if="setupStep === 'init'">
|
|
|
|
|
|
<v-progress-circular indeterminate />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="setupStep === 'confirm'">
|
|
|
|
|
|
<p class="mb-3">
|
|
|
|
|
|
Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Yandex Key) и
|
|
|
|
|
|
введите 6-значный код, который оно покажет.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-caption text-medium-emphasis mb-3">
|
|
|
|
|
|
Если QR не сканируется, введите secret вручную:
|
|
|
|
|
|
<strong class="font-mono">{{ setupSecret }}</strong>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<v-text-field
|
|
|
|
|
|
v-model="setupCode"
|
|
|
|
|
|
label="6-значный код"
|
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
|
maxlength="6"
|
|
|
|
|
|
:error-messages="setupError ? [setupError] : []"
|
|
|
|
|
|
density="comfortable"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="setupStep === 'codes'">
|
|
|
|
|
|
<v-alert type="warning" variant="tonal" class="mb-3">
|
|
|
|
|
|
Сохраните 8 резервных кодов сейчас — они показываются один раз!
|
|
|
|
|
|
</v-alert>
|
|
|
|
|
|
<div class="codes-grid">
|
|
|
|
|
|
<div v-for="(c, i) in setupRecoveryCodes" :key="i" class="code-item font-mono">
|
|
|
|
|
|
{{ c }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
<v-card-actions>
|
|
|
|
|
|
<v-spacer />
|
|
|
|
|
|
<v-btn v-if="setupStep === 'confirm'" :loading="setupBusy" color="primary" @click="confirmSetup">
|
|
|
|
|
|
Подтвердить
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
<v-btn v-if="setupStep === 'codes'" color="primary" @click="closeSetup">Готово</v-btn>
|
|
|
|
|
|
<v-btn v-if="setupStep !== 'codes'" variant="text" @click="closeSetup">Отмена</v-btn>
|
|
|
|
|
|
</v-card-actions>
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
</v-dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- DISABLE DIALOG -->
|
|
|
|
|
|
<v-dialog v-model="disableOpen" :max-width="420" data-testid="disable-dialog">
|
|
|
|
|
|
<v-card>
|
|
|
|
|
|
<v-card-title>Отключить 2FA</v-card-title>
|
|
|
|
|
|
<v-card-text>
|
|
|
|
|
|
<p class="mb-3">Введите пароль, чтобы подтвердить отключение двухфакторной авторизации.</p>
|
|
|
|
|
|
<v-text-field
|
|
|
|
|
|
v-model="disablePassword"
|
|
|
|
|
|
label="Пароль"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
autocomplete="current-password"
|
|
|
|
|
|
:error-messages="disableError ? [disableError] : []"
|
|
|
|
|
|
density="comfortable"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
<v-card-actions>
|
|
|
|
|
|
<v-spacer />
|
|
|
|
|
|
<v-btn color="error" @click="confirmDisable">Отключить</v-btn>
|
|
|
|
|
|
<v-btn variant="text" @click="disableOpen = false">Отмена</v-btn>
|
|
|
|
|
|
</v-card-actions>
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
</v-dialog>
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.codes-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.code-item {
|
|
|
|
|
|
background: #f6f3ec;
|
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|