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:
2026-09-26 15:12:43 +03:00
parent 8e096ca6b2
commit cd1d662572
23 changed files with 2878 additions and 18 deletions
+263
View File
@@ -0,0 +1,263 @@
/**
* Обёртка над `navigator.credentials` для passkeys (WebAuthn).
*
* Тяжёлые SDK не используем (AGENT.md 5.2): сервер отдаёт обычный JSON
* WebAuthn, здесь он переводится в ArrayBuffer и обратно. Все ошибки
* приводятся к `WebAuthnClientError` с понятным кодом, чтобы UI показал
* человеческое сообщение, а не `NotAllowedError`.
*/
/** Коды ошибок клиента: используются в i18n (`errors.auth.passkey_*`). */
export type WebAuthnClientErrorCode =
'unsupported' | 'cancelled' | 'timeout' | 'invalid_state' | 'not_allowed' | 'failed';
export class WebAuthnClientError extends Error {
readonly code: WebAuthnClientErrorCode;
constructor(code: WebAuthnClientErrorCode, message?: string) {
super(message ?? code);
this.name = 'WebAuthnClientError';
this.code = code;
}
}
/** Публичные опции, как их отдаёт сервер (base64url-строки). */
export interface PublicKeyCredentialCreationOptionsJSON {
challenge: string;
rp: { id?: string; name: string };
user: { id: string; name: string; displayName: string };
pubKeyCredParams: PublicKeyCredentialParameters[];
timeout?: number;
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
excludeCredentials?: { id: string; type: 'public-key'; transports?: string[] }[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialRequestOptionsJSON {
challenge: string;
timeout?: number;
rpId?: string;
allowCredentials?: { id: string; type: 'public-key'; transports?: string[] }[];
userVerification?: UserVerificationRequirement;
}
/** Ответ аутентификатора в JSON-виде — ровно то, что ждёт сервер. */
export interface RegistrationCredentialJSON {
id: string;
rawId: string;
type: 'public-key';
response: {
clientDataJSON: string;
attestationObject: string;
transports?: string[];
};
clientExtensionResults: Record<string, unknown>;
authenticatorAttachment?: string;
}
export interface AssertionCredentialJSON {
id: string;
rawId: string;
type: 'public-key';
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle?: string;
};
clientExtensionResults: Record<string, unknown>;
authenticatorAttachment?: string;
}
/** base64url → ArrayBuffer (браузер не умеет декодировать сам). */
export function base64URLToBuffer(value: string): ArrayBuffer {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
const binary = atob(padded + pad);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
/** ArrayBuffer → base64url без паддинга. */
export function bufferToBase64URL(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* Поддерживает ли браузер passkeys. Проверяем и наличие API, и защищённый
* контекст: без HTTPS (кроме localhost) WebAuthn недоступен.
*/
export function isPasskeySupported(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
if (typeof window.PublicKeyCredential === 'undefined') {
return false;
}
return (
typeof navigator.credentials?.create === 'function' &&
typeof navigator.credentials?.get === 'function'
);
}
/** Приводит произвольную ошибку браузера к понятному коду. */
export function mapCredentialsError(error: unknown): WebAuthnClientError {
if (error instanceof WebAuthnClientError) {
return error;
}
const name = error instanceof Error ? error.name : '';
switch (name) {
case 'NotAllowedError':
// Пользователь отменил диалог или истёк таймаут: браузер не различает их.
return new WebAuthnClientError('cancelled', name);
case 'AbortError':
return new WebAuthnClientError('cancelled', name);
case 'TimeoutError':
return new WebAuthnClientError('timeout', name);
case 'InvalidStateError':
// Ключ уже зарегистрирован на этом устройстве.
return new WebAuthnClientError('invalid_state', name);
case 'NotSupportedError':
case 'SecurityError':
return new WebAuthnClientError('unsupported', name);
default:
return new WebAuthnClientError('failed', name);
}
}
/** Дескриптор ключа в формате браузера: id — ArrayBuffer. */
function toDescriptors(
descriptors: { id: string; type: 'public-key'; transports?: string[] }[] | undefined,
): PublicKeyCredentialDescriptor[] | undefined {
if (descriptors === undefined) {
return undefined;
}
return descriptors.map((descriptor) => ({
id: base64URLToBuffer(descriptor.id),
type: descriptor.type,
...(descriptor.transports === undefined
? {}
: { transports: descriptor.transports as AuthenticatorTransport[] }),
}));
}
function requireSupport(): void {
if (!isPasskeySupported()) {
throw new WebAuthnClientError('unsupported');
}
}
/**
* Создаёт ключ доступа и возвращает ответ для сервера.
* `options` — поле `publicKey` из ответа `/auth/passkeys/register/begin`.
*/
export async function createPasskey(
options: PublicKeyCredentialCreationOptionsJSON,
): Promise<RegistrationCredentialJSON> {
requireSupport();
const excludeCredentials = toDescriptors(options.excludeCredentials);
// Поля собираем поимённо: JSON-вариант содержит строковые id, и простой
// спред перенёс бы их в браузерный тип, где нужен ArrayBuffer.
const publicKey: PublicKeyCredentialCreationOptions = {
challenge: base64URLToBuffer(options.challenge),
rp: options.rp,
user: { ...options.user, id: base64URLToBuffer(options.user.id) },
pubKeyCredParams: options.pubKeyCredParams,
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
...(options.attestation === undefined ? {} : { attestation: options.attestation }),
...(options.authenticatorSelection === undefined
? {}
: { authenticatorSelection: options.authenticatorSelection }),
...(options.extensions === undefined ? {} : { extensions: options.extensions }),
...(excludeCredentials === undefined ? {} : { excludeCredentials }),
};
try {
const credential = (await navigator.credentials.create({
publicKey,
})) as PublicKeyCredential | null;
if (credential === null) {
throw new WebAuthnClientError('cancelled');
}
const response = credential.response as AuthenticatorAttestationResponse;
const result: RegistrationCredentialJSON = {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: 'public-key',
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
attestationObject: bufferToBase64URL(response.attestationObject),
},
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
};
if (typeof response.getTransports === 'function') {
const transports = response.getTransports();
if (transports.length > 0) {
result.response.transports = transports;
}
}
if (credential.authenticatorAttachment !== null) {
result.authenticatorAttachment = credential.authenticatorAttachment;
}
return result;
} catch (error) {
throw mapCredentialsError(error);
}
}
/**
* Подписывает challenge существующим ключом.
* `options` — поле `publicKey` из ответа `/auth/passkeys/login/begin`.
*/
export async function getPasskeyAssertion(
options: PublicKeyCredentialRequestOptionsJSON,
): Promise<AssertionCredentialJSON> {
requireSupport();
const allowCredentials = toDescriptors(options.allowCredentials);
const publicKey: PublicKeyCredentialRequestOptions = {
challenge: base64URLToBuffer(options.challenge),
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
...(options.rpId === undefined ? {} : { rpId: options.rpId }),
...(options.userVerification === undefined
? {}
: { userVerification: options.userVerification }),
...(allowCredentials === undefined ? {} : { allowCredentials }),
};
try {
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null;
if (credential === null) {
throw new WebAuthnClientError('cancelled');
}
const response = credential.response as AuthenticatorAssertionResponse;
const result: AssertionCredentialJSON = {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: 'public-key',
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
authenticatorData: bufferToBase64URL(response.authenticatorData),
signature: bufferToBase64URL(response.signature),
},
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
};
if (response.userHandle !== null) {
result.response.userHandle = bufferToBase64URL(response.userHandle);
}
if (credential.authenticatorAttachment !== null) {
result.authenticatorAttachment = credential.authenticatorAttachment;
}
return result;
} catch (error) {
throw mapCredentialsError(error);
}
}