feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 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.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user