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.
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
||
* Обёртка над `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);
|
||
}
|
||
}
|