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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user