112 lines
5.8 KiB
TypeScript
112 lines
5.8 KiB
TypeScript
import { api } from './client'
|
||
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } 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 对一段文本签名(挑战是文本的 SHA-256):操作员集合、加入码的委托都是这样签出来的,节点自己验。
|
||
* 浏览器会让用户从这个站点的 passkey 里选一个;面板再检查它是不是操作员集合里的凭证。
|
||
*/
|
||
export async function signStatement(statement: string): Promise<SignedStatementDto> {
|
||
const challenge = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(statement)))
|
||
let credential: PublicKeyCredential | null
|
||
try {
|
||
credential = (await navigator.credentials.get({ publicKey: { challenge, userVerification: 'required', timeout: 120_000 } })) as PublicKeyCredential | null
|
||
} catch (e) {
|
||
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) throw new Error('已取消')
|
||
throw e
|
||
}
|
||
if (!credential) throw new Error('没有选择 passkey')
|
||
const response = credential.response as AuthenticatorAssertionResponse
|
||
return {
|
||
statement,
|
||
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?.() ?? [],
|
||
})
|
||
}
|