c39d555e6f
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { defineStore } from 'pinia';
|
||
import { computed, ref } from 'vue';
|
||
import * as authApi from '../api/auth';
|
||
import type { AuthUser, LoginPayload, RegisterPayload, ResetPasswordPayload } from '../api/auth';
|
||
import { extractRateLimitRetry } from '../api/client';
|
||
|
||
/**
|
||
* Auth-store: состояние текущего user'а + auth-actions через Sanctum SPA.
|
||
*
|
||
* Использование:
|
||
* const auth = useAuthStore();
|
||
* await auth.login({ email, password }); // редирект на /dashboard или /2fa
|
||
* await auth.fetchMe(); // restore session при старте app
|
||
* if (auth.isAuthenticated) { ... }
|
||
* await auth.logout();
|
||
*
|
||
* Не входит:
|
||
* - Persist user в localStorage (session-cookie держит state на backend; при
|
||
* page-reload `fetchMe()` восстанавливает user). Если cookie expired — 401
|
||
* и redirect на /login через Vue Router auth-guard.
|
||
*/
|
||
export const useAuthStore = defineStore('auth', () => {
|
||
const user = ref<AuthUser | null>(null);
|
||
const loading = ref(false);
|
||
const requires2fa = ref(false);
|
||
/** Секунды до следующей разрешённой попытки login/2fa-verify (ТЗ §22.4.4). */
|
||
const lockoutSeconds = ref<number | null>(null);
|
||
|
||
const isAuthenticated = computed(() => user.value !== null);
|
||
|
||
async function login(payload: LoginPayload) {
|
||
loading.value = true;
|
||
lockoutSeconds.value = null;
|
||
try {
|
||
const response = await authApi.login(payload);
|
||
// При requires_2fa=true НЕ ставим user в state — иначе isAuthenticated
|
||
// станет true и auth-guard пустит на /dashboard минуя 2FA. Backend
|
||
// тоже НЕ создаёт session-auth до verifyTwoFactor.
|
||
if (response.requires_2fa) {
|
||
user.value = null;
|
||
requires2fa.value = true;
|
||
} else {
|
||
user.value = response.user;
|
||
requires2fa.value = false;
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
const retry = extractRateLimitRetry(error);
|
||
if (retry !== null) lockoutSeconds.value = retry;
|
||
throw error;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function register(payload: RegisterPayload) {
|
||
loading.value = true;
|
||
try {
|
||
const response = await authApi.register(payload);
|
||
user.value = response.user;
|
||
requires2fa.value = response.requires_2fa;
|
||
return response;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function requestPasswordReset(email: string) {
|
||
loading.value = true;
|
||
lockoutSeconds.value = null;
|
||
try {
|
||
const response = await authApi.forgotPassword(email);
|
||
return response;
|
||
} catch (error) {
|
||
const retry = extractRateLimitRetry(error);
|
||
if (retry !== null) lockoutSeconds.value = retry;
|
||
throw error;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function resetPassword(payload: ResetPasswordPayload) {
|
||
loading.value = true;
|
||
lockoutSeconds.value = null;
|
||
try {
|
||
const response = await authApi.resetPassword(payload);
|
||
return response;
|
||
} catch (error) {
|
||
const retry = extractRateLimitRetry(error);
|
||
if (retry !== null) lockoutSeconds.value = retry;
|
||
throw error;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function verifyTwoFactor(code: string) {
|
||
loading.value = true;
|
||
lockoutSeconds.value = null;
|
||
try {
|
||
const response = await authApi.verifyTwoFactor(code);
|
||
user.value = response.user;
|
||
requires2fa.value = false;
|
||
return response;
|
||
} catch (error) {
|
||
const retry = extractRateLimitRetry(error);
|
||
if (retry !== null) lockoutSeconds.value = retry;
|
||
throw error;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function useRecoveryCode(code: string) {
|
||
loading.value = true;
|
||
lockoutSeconds.value = null;
|
||
try {
|
||
const response = await authApi.useRecoveryCode(code);
|
||
user.value = response.user;
|
||
requires2fa.value = false;
|
||
return response;
|
||
} catch (error) {
|
||
const retry = extractRateLimitRetry(error);
|
||
if (retry !== null) lockoutSeconds.value = retry;
|
||
throw error;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchMe(): Promise<AuthUser | null> {
|
||
try {
|
||
const fetched = await authApi.me();
|
||
user.value = fetched;
|
||
return fetched;
|
||
} catch {
|
||
user.value = null;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
// Логаут всегда успешен с точки зрения UI: даже если backend упал —
|
||
// клиент локально считается вышедшим. Иначе пользователь может остаться
|
||
// «залипшим» в авторизованном состоянии при сетевой ошибке.
|
||
try {
|
||
await authApi.logout();
|
||
} catch {
|
||
// ignore — backend всё равно очистит сессию по cookie-expiry.
|
||
}
|
||
user.value = null;
|
||
requires2fa.value = false;
|
||
}
|
||
|
||
return {
|
||
user,
|
||
loading,
|
||
requires2fa,
|
||
lockoutSeconds,
|
||
isAuthenticated,
|
||
login,
|
||
register,
|
||
verifyTwoFactor,
|
||
useRecoveryCode,
|
||
requestPasswordReset,
|
||
resetPassword,
|
||
fetchMe,
|
||
logout,
|
||
};
|
||
});
|