cd1d662572
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты — обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование, события безопасности и аудит. Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin берутся из конфига домена; если домен — IP-адрес (стенд без домена), passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в памяти процесса 5 минут и одноразовые: повторная отправка challenge отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу webauthn_credentials под полную запись credential в JSON. Новые ручки входа ограничены по IP (10/мин). Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK, раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу» на экране входа; понятные сообщения для браузеров без поддержки WebAuthn и при отмене диалога. Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная проверка подписи P-256), отказ при чужом challenge, одноразовость церемонии, обязательный step-up при управлении ключами, запрет входа забаненному, ограничение allowCredentials при входе с почтой, лимит и валидация имени; web — 12 vitest с моком navigator.credentials; Playwright — живой сценарий с виртуальным аутентификатором Chromium.
134 lines
6.4 KiB
TypeScript
134 lines
6.4 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
|
|
import {
|
|
apiAs,
|
|
ensureProbe,
|
|
freshState,
|
|
repeat429,
|
|
type Requester,
|
|
type StorageState,
|
|
} from './support';
|
|
|
|
/** Префикс API инстанса: те же адреса, что у клиента. */
|
|
const API = '/api/v1';
|
|
|
|
/**
|
|
* passkey.spec.ts — живая проверка passkeys (WebAuthn) на стенде
|
|
* (AGENT.md 7.1, Фаза 7): регистрация ключа в настройках безопасности,
|
|
* вход по нему без пароля и отзыв ключа.
|
|
*
|
|
* Вместо настоящего Touch ID используем виртуальный аутентификатор Chromium
|
|
* (CDP `WebAuthn.enable` + `addVirtualAuthenticator`): подпись считает сам
|
|
* браузер, поэтому проверяется весь путь — challenge, RP ID, origin, проверка
|
|
* подписи на сервере и выдача сессии.
|
|
*/
|
|
|
|
const PASSWORD = 'correct-horse-battery-1';
|
|
const USERNAME = 'e2e_passkey';
|
|
|
|
/** addVirtualAuthenticator включает виртуальный ключ в контексте страницы. */
|
|
async function addVirtualAuthenticator(page: Page): Promise<void> {
|
|
const session = await page.context().newCDPSession(page);
|
|
await session.send('WebAuthn.enable');
|
|
await session.send('WebAuthn.addVirtualAuthenticator', {
|
|
options: {
|
|
protocol: 'ctap2',
|
|
transport: 'internal',
|
|
hasResidentKey: true,
|
|
hasUserVerification: true,
|
|
isUserVerified: true,
|
|
automaticPresenceSimulation: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
test.describe('passkeys (WebAuthn)', () => {
|
|
test('регистрация ключа, вход по ключу без пароля и отзыв', async ({ browser, playwright }) => {
|
|
const api = await playwright.request.newContext({ baseURL: process.env.GLCHAT_URL });
|
|
await ensureProbe(api, USERNAME, PASSWORD);
|
|
|
|
// Инстанс обязан объявлять поддержку passkeys: домен стенда — не IP.
|
|
const meta = await repeat429(() => api.get(`${API}/meta`));
|
|
expect(meta.ok(), `GET /meta: ${await meta.text()}`).toBeTruthy();
|
|
const metaPayload = (await meta.json()) as { features: { passkeys_enabled: boolean } };
|
|
expect(metaPayload.features.passkeys_enabled).toBe(true);
|
|
|
|
const state = await freshState(api, { username: USERNAME, password: PASSWORD });
|
|
const context = await browser.newContext({ storageState: state });
|
|
const page = await context.newPage();
|
|
await addVirtualAuthenticator(page);
|
|
const keyName = `E2E ключ ${Date.now()}`;
|
|
|
|
try {
|
|
// 1. Регистрация ключа в настройках безопасности (со step-up паролем).
|
|
await page.goto('/settings/account/security');
|
|
await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByRole('heading', { name: 'Ключи доступа (passkeys)' })).toBeVisible();
|
|
|
|
const section = page.getByTestId('settings-content');
|
|
await section.getByLabel('Название ключа').fill(keyName);
|
|
await section.getByLabel('Ваш пароль').fill(PASSWORD);
|
|
await section.getByRole('button', { name: 'Создать ключ' }).click();
|
|
await expect(page.getByText(`Ключ «${keyName}» добавлен.`)).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await expect(section.getByTestId('passkey-name').filter({ hasText: keyName })).toBeVisible();
|
|
|
|
// 2. Вход по ключу из того же браузера, но без сессии: пароль не нужен.
|
|
await context.clearCookies();
|
|
await page.goto('/login');
|
|
const passkeyButton = page.getByTestId('passkey-login');
|
|
await expect(passkeyButton).toBeVisible({ timeout: 20_000 });
|
|
await passkeyButton.click();
|
|
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
|
|
|
|
// Сессия рабочая: профиль отдаётся сервером.
|
|
const me = await page.request.get(`${API}/users/@me`);
|
|
expect(me.ok(), `GET /users/@me после входа по ключу: ${await me.text()}`).toBeTruthy();
|
|
|
|
// Вход записан в события безопасности (аудит passkeys).
|
|
const events = await page.request.get(`${API}/users/@me/security-events`);
|
|
expect(events.ok(), `GET /users/@me/security-events: ${await events.text()}`).toBeTruthy();
|
|
expect(await events.text()).toContain('passkey_login');
|
|
|
|
// 3. Отзыв ключа: список очищается, вход этим ключом больше не проходит.
|
|
await page.goto('/settings/account/security');
|
|
const again = page.getByTestId('settings-content');
|
|
await expect(again.getByText(keyName)).toBeVisible({ timeout: 30_000 });
|
|
// Отзыв требует свежего step-up: пароль берётся из формы раздела.
|
|
await again.getByLabel('Ваш пароль').fill(PASSWORD);
|
|
await again.getByRole('button', { name: 'Отозвать', exact: true }).first().click();
|
|
await again.getByRole('button', { name: 'Отозвать ключ' }).click();
|
|
await expect(page.getByText('Ключ отозван.')).toBeVisible({ timeout: 20_000 });
|
|
|
|
await context.clearCookies();
|
|
await page.goto('/login');
|
|
await page.getByTestId('passkey-login').click();
|
|
// Ключа больше нет: сервер отвечает понятной ошибкой, в приложение не пускает.
|
|
await expect(page.getByTestId('passkey-error')).toBeVisible({ timeout: 20_000 });
|
|
await expect(page).toHaveURL(/\/login/u);
|
|
} finally {
|
|
await context.close();
|
|
await dropPasskeys(playwright, state);
|
|
await api.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
/** dropPasskeys убирает ключи тестового аккаунта, чтобы стенд не копил данные. */
|
|
async function dropPasskeys(playwright: Requester, state: StorageState): Promise<void> {
|
|
const api = await apiAs(playwright, state);
|
|
try {
|
|
const response = await api.get(`${API}/auth/passkeys`);
|
|
if (!response.ok()) {
|
|
return;
|
|
}
|
|
const { passkeys } = (await response.json()) as { passkeys: { id: string }[] };
|
|
for (const passkey of passkeys) {
|
|
await api.delete(`${API}/auth/passkeys/${passkey.id}`);
|
|
}
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|