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

127 lines
6.2 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),
})
}
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
export type ElevationClass = 'enroll' | 'revoke' | 'credential'
export const ELEVATED_EVENT = 'moshiro:elevated'
/**
* 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。
* 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。
*/
export async function elevate(elevation: ElevationClass): Promise<boolean> {
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/elevation/options', { class: elevation })
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey: {
challenge: toBytes(options.challenge),
rpId: options.rp_id,
timeout: options.timeout_ms,
userVerification: 'required',
allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
},
})) as PublicKeyCredential | null
} catch (e) {
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false
throw e
}
if (!credential) return false
const response = credential.response as AuthenticatorAssertionResponse
await api.post('/api/admin/auth/elevation', {
class: elevation,
credential_id: credential.id,
authenticator_data: toText(response.authenticatorData),
client_data_json: toText(response.clientDataJSON),
signature: toText(response.signature),
})
window.dispatchEvent(new Event(ELEVATED_EVENT))
return true
}
/** 给当前登录的用户注册一个新 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?.() ?? [],
})
}