Files
MoShiro/web/shared/api/webauthn.ts
T

88 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from './client'
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
export async function conditionalSupported(): Promise<boolean> {
try {
return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true
} catch {
return false
}
}
/**
* passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。
* `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串);
* `external`:安全密钥,或者用手机扫码。
*/
export type PasskeyPlace = 'device' | 'external'
/** 浏览器是否支持 WebAuthn(passkey)。 */
export function passkeySupported(): boolean {
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
}
function toBytes(text: string): Uint8Array<ArrayBuffer> {
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=')
const binary = atob(padded)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return out
}
function toText(buffer: ArrayBuffer): string {
let binary = ''
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。
* passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。
*/
export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise<LoginResponse> {
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/passkey/options')
const credential = (await navigator.credentials.get({
// conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的)
mediation: opts.conditional ? 'conditional' : undefined,
signal: opts.signal,
publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' },
})) as PublicKeyCredential | null
if (!credential) throw new Error('没有选择 passkey')
const response = credential.response as AuthenticatorAssertionResponse
return api.post<LoginResponse>('/api/admin/auth/passkey/login', {
credential_id: credential.id,
authenticator_data: toText(response.authenticatorData),
client_data_json: toText(response.clientDataJSON),
signature: toText(response.signature),
})
}
/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')
const credential = (await navigator.credentials.create({
publicKey: {
challenge: toBytes(options.challenge),
rp: { id: options.rp_id, name: options.rp_name },
user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: options.timeout_ms,
// 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机
hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'],
} as PublicKeyCredentialCreationOptions,
})) as PublicKeyCredential | null
if (!credential) throw new Error('没有创建 passkey')
const response = credential.response as AuthenticatorAttestationResponse
return api.post<PasskeyDto>('/api/admin/auth/passkeys', {
name,
credential_id: credential.id,
attestation_object: toText(response.attestationObject),
client_data_json: toText(response.clientDataJSON),
transports: response.getTransports?.() ?? [],
})
}