import type {
DeleteWebAuthnCredentialRequest,
DeleteWebAuthnCredentialResult,
RedirectResult,
ResponseResult,
SessionMetadataPayload,
WebAuthnCredentialViewModel,
} from "../models/AuthModels";
import { ApiHttpClient } from "./ApiHttpClient";
import Bowser from "bowser";
const WEBAUTHN_TIMEOUT_MS = 60000;
export class WebAuthnService {
constructor(private readonly http = new ApiHttpClient()) {}
async list() {
return await this.http.get<WebAuthnCredentialViewModel[]>("/api/auth/webauthn/credentials");
}
async register(displayName = this.createDisplayName()) {
const optionsResult = await this.http.post<PublicKeyCredentialCreationOptions>(
"/api/auth/webauthn/register/options",
{ displayName },
);
if (!optionsResult.success || !optionsResult.data) {
return {
success: false,
code: optionsResult.code,
message: optionsResult.message,
};
}
const credential = await this.createCredential(
this.decodeCreationOptions(optionsResult.data),
);
return await this.http.post("/api/auth/webauthn/register/complete", {
displayName,
credential: this.serializeCredential(credential),
});
}
async signIn(
account: string | undefined,
returnUrl: string | undefined,
remember: boolean,
sessionInfo?: SessionMetadataPayload,
): Promise<ResponseResult<RedirectResult>> {
const optionsResult = await this.http.post<PublicKeyCredentialRequestOptions>(
"/api/auth/webauthn/assertion/options",
{ account },
);
if (!optionsResult.success || !optionsResult.data) {
return {
success: false,
code: optionsResult.code,
message: optionsResult.message,
};
}
const credential = await this.getCredential(this.decodeRequestOptions(optionsResult.data));
return await this.http.post<RedirectResult>("/api/auth/webauthn/assertion/complete", {
credential: this.serializeCredential(credential),
challenge: optionsResult.data.challenge,
returnUrl,
remember,
sessionInfo,
});
}
async deleteCredential(
credentialId: string,
request: DeleteWebAuthnCredentialRequest = {},
): Promise<ResponseResult<DeleteWebAuthnCredentialResult>> {
return await this.http.post<DeleteWebAuthnCredentialResult>(
`/api/auth/webauthn/credentials/${encodeURIComponent(credentialId)}/delete`,
request,
);
}
/** 用于受 MFA 保护的流程:从服务端下发的参数发起浏览器断言(不做网络请求)。 */
async createAssertion(assertionOptions: PublicKeyCredentialRequestOptions) {
const credential = await this.getCredential(this.decodeRequestOptions(assertionOptions));
return this.serializeCredential(credential);
}
isWebAuthnSupported() {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
"PublicKeyCredential" in window &&
"credentials" in navigator &&
typeof navigator.credentials?.create === "function" &&
typeof navigator.credentials?.get === "function"
);
}
createDisplayName() {
const parser = Bowser.getParser(navigator.userAgent);
const browser = parser.getBrowser();
const os = parser.getOS();
const platform = parser.getPlatform();
const parts = [
platform.model || os.name || this.toDeviceTypeName(platform.type),
browser.name,
this.formatDisplayDate(new Date()),
].filter((value): value is string => Boolean(value));
return parts.join(" · ");
}
toFriendlyError(error: unknown, context: { discoverable?: boolean } = {}) {
if (!(error instanceof DOMException)) {
return error instanceof Error ? error.message : "Passkey 操作失败,请重试";
}
const discoverableMessage =
"Passkey 操作未完成。当前是无账号发现式登录,浏览器会列出所有 localhost 通行密钥;如果本机存在多个旧凭据,请先输入账号再登录,或删除旧的 localhost Passkey 后重试";
switch (error.name) {
case "NotAllowedError":
if (context.discoverable) {
return discoverableMessage;
}
return "Passkey 操作已取消,或当前浏览器没有这个站点的可用凭证;如果已绑定,请先输入账号再试";
case "TimeoutError":
case "AbortError":
if (context.discoverable) {
return discoverableMessage;
}
return "Passkey 操作超时,请重新点击后完成系统验证";
case "SecurityError":
return "当前站点或浏览器安全上下文不允许使用 Passkey";
case "InvalidStateError":
return "该设备可能已经绑定过 Passkey,请换一个凭证或刷新列表";
case "NotSupportedError":
return "当前浏览器或认证器不支持本次 Passkey 操作";
case "UnknownError":
if (context.discoverable) {
return discoverableMessage;
}
return "Passkey 操作遇到浏览器或系统的临时错误,请关闭弹窗后重新点击;如果连续失败,请重新绑定 Passkey";
default:
return error.message || "Passkey 操作失败,请重试";
}
}
private decodeCreationOptions(options: PublicKeyCredentialCreationOptions) {
return {
...options,
challenge: this.base64UrlToArrayBuffer(options.challenge as unknown as string),
user: {
...options.user,
id: this.base64UrlToArrayBuffer(options.user.id as unknown as string),
},
excludeCredentials: options.excludeCredentials?.map((credential) => ({
...credential,
id: this.base64UrlToArrayBuffer(credential.id as unknown as string),
})),
};
}
decodeRequestOptions(options: PublicKeyCredentialRequestOptions) {
const decoded = {
...options,
challenge: this.base64UrlToArrayBuffer(options.challenge as unknown as string),
};
if (options.allowCredentials?.length) {
decoded.allowCredentials = options.allowCredentials.map((credential) => ({
...credential,
id: this.base64UrlToArrayBuffer(credential.id as unknown as string),
}));
} else {
delete decoded.allowCredentials;
}
return decoded;
}
private async createCredential(publicKey: PublicKeyCredentialCreationOptions) {
const credential = await this.withAbort((signal) =>
navigator.credentials.create({
publicKey,
signal,
}),
);
if (!credential) {
throw new DOMException("No credential returned", "NotAllowedError");
}
return credential;
}
private async getCredential(publicKey: PublicKeyCredentialRequestOptions) {
const credential = await this.withAbort((signal) =>
navigator.credentials.get({
publicKey,
signal,
}),
);
if (!credential) {
throw new DOMException("No credential returned", "NotAllowedError");
}
return credential;
}
private async withAbort<T>(operation: (signal: AbortSignal) => Promise<T>) {
const controller = new AbortController();
let timer = 0;
const timeout = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => {
controller.abort();
reject(new DOMException("Passkey operation timed out", "TimeoutError"));
}, WEBAUTHN_TIMEOUT_MS);
});
try {
return await Promise.race([operation(controller.signal), timeout]);
} finally {
window.clearTimeout(timer);
}
}
private base64UrlToArrayBuffer(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(
normalized.length + ((4 - (normalized.length % 4)) % 4),
"=",
);
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return buffer;
}
serializeCredential(credential: Credential | null) {
if (!this.isPublicKeyCredential(credential)) {
return credential;
}
const response = credential.response;
const result: Record<string, unknown> = {
id: credential.id,
rawId: this.arrayBufferToBase64Url(credential.rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: credential.authenticatorAttachment,
};
if (this.isAttestationResponse(response)) {
result.response = {
attestationObject: this.arrayBufferToBase64Url(response.attestationObject),
clientDataJSON: this.arrayBufferToBase64Url(response.clientDataJSON),
transports: response.getTransports(),
};
return result;
}
if (this.isAssertionResponse(response)) {
result.response = {
authenticatorData: this.arrayBufferToBase64Url(response.authenticatorData),
clientDataJSON: this.arrayBufferToBase64Url(response.clientDataJSON),
signature: this.arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle
? this.arrayBufferToBase64Url(response.userHandle)
: null,
};
return result;
}
throw new Error("Passkey 返回数据格式无效");
}
private arrayBufferToBase64Url(value: ArrayBuffer) {
const bytes = new Uint8Array(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
private toDeviceTypeName(deviceType: string | undefined) {
switch (deviceType) {
case "mobile":
return "Mobile";
case "tablet":
return "Tablet";
case "tv":
return "TV";
default:
return "Desktop";
}
}
private formatDisplayDate(value: Date) {
const pad = (part: number) => String(part).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
value.getDate(),
)} ${pad(value.getHours())}:${pad(value.getMinutes())}`;
}
private isPublicKeyCredential(
credential: Credential | null,
): credential is PublicKeyCredential {
return (
credential !== null &&
credential.type === "public-key" &&
"rawId" in credential &&
"response" in credential &&
typeof (credential as PublicKeyCredential).getClientExtensionResults ===
"function"
);
}
private isAttestationResponse(
response: AuthenticatorResponse,
): response is AuthenticatorAttestationResponse {
return "attestationObject" in response;
}
private isAssertionResponse(
response: AuthenticatorResponse,
): response is AuthenticatorAssertionResponse {
return "authenticatorData" in response && "signature" in response;
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
WebAuthn 服务代码解析
这是一个完整的 WebAuthn(Web Authentication API)服务类,用于实现基于 Passkey 的无密码认证功能。以下是详细解析:
核心功能概览
这个服务封装了 WebAuthn 的完整生命周期:
- 凭证注册(Register)- 绑定新的 Passkey
- 身份断言(Sign In)- 使用 Passkey 登录
- 凭证管理(List/Delete)- 查看和删除已绑定的凭证
- 错误处理 - 友好的用户提示
主要方法详解
1. 凭证注册流程 (register)
async register(displayName = this.createDisplayName())
- 获取服务器下发的创建选项(challenge、用户信息等)
- 调用浏览器 API 创建新凭证
- 将凭证数据序列化后发送给服务器完成注册
- 自动生成设备显示名称(如 "Desktop · Chrome · 2024-01-15 10:30")
2. 登录流程 (signIn)
async signIn(account, returnUrl, remember, sessionInfo)
- 支持两种模式:
- 账号绑定模式:传入 account,只验证该用户的凭证
- 可发现模式:不传 account,浏览器列出所有可用凭证
- 获取服务器 challenge → 用户验证 → 提交断言结果
- 支持"记住我"和会话元数据传递
3. 凭证管理
list()- 获取当前用户所有绑定的 PasskeydeleteCredential()- 删除指定凭证
4. MFA 场景 (createAssertion)
用于多因素认证流程,服务器下发 challenge 后本地完成验证,无需额外网络请求。
关键技术处理
数据编码转换
WebAuthn API 使用 ArrayBuffer,而网络传输使用 Base64URL:
// Base64URL → ArrayBuffer(用于浏览器 API)
base64UrlToArrayBuffer(value: string)
// ArrayBuffer → Base64URL(用于网络传输)
arrayBufferToBase64Url(value: ArrayBuffer)
凭证序列化
将浏览器返回的复杂对象转换为可传输的 JSON:
serializeCredential(credential)
处理两种响应类型:
- AttestationResponse(注册)- 包含公钥、设备信息
- AssertionResponse(登录)- 包含签名、用户句柄
超时控制
使用 AbortController 实现 60 秒超时:
private async withAbort<T>(operation)
防止用户长时间未操作导致界面卡死。
错误处理与用户体验
toFriendlyError 方法
将 WebAuthn 的技术性错误转换为用户友好提示:
| DOMException 错误 | 用户提示 |
|---|---|
NotAllowedError | "操作已取消,或没有可用凭证" |
TimeoutError | "操作超时,请重新验证" |
InvalidStateError | "设备已绑定,请换一个凭证" |
SecurityError | "站点安全上下文不允许" |
特殊处理可发现模式(discoverable)的错误提示。
环境检测
isWebAuthnSupported
检查浏览器兼容性:
- 必须是 HTTPS(安全上下文)
- 支持
PublicKeyCredential - 存在
navigator.credentialsAPI
设备信息生成
使用 Bowser 库解析 UserAgent:
createDisplayName() // 示例输出: "iPhone · Safari · 2024-01-15 10:30"
类型守卫(Type Guards)
三个私有方法确保类型安全:
isPublicKeyCredential() // 验证是否为 PublicKeyCredential
isAttestationResponse() // 判断是注册响应
isAssertionResponse() // 判断是断言响应
安全特性
- Challenge 防重放 - 每次操作使用服务器下发的一次性 challenge
- 超时保护 - 60 秒自动中止操作
- HTTPS 强制 - 仅在安全上下文工作
- 凭证隔离 -
excludeCredentials防止重复绑定
依赖项
ApiHttpClient- 封装的 HTTP 客户端Bowser- UserAgent 解析库- 浏览器原生
navigator.credentialsAPI
典型使用场景
const service = new WebAuthnService();
// 注册 Passkey
await service.register("我的 iPhone");
// 登录(账号绑定模式)
await service.signIn("user@example.com", "/dashboard", true);
// 登录(可发现模式)
await service.signIn(undefined, "/", false);
// 删除凭证
await service.deleteCredential("credential-id-123");
总结
这是一个生产级别的 WebAuthn 实现,具有:
- ✅ 完整的注册/登录流程
- ✅ 友好的错误处理
- ✅ 完善的类型安全
- ✅ 良好的用户体验(自动生成设备名、超时控制)
- ✅ 适配多种场景(MFA、可发现模式)
代码遵循现代 TypeScript 最佳实践,适合作为 Passkey 功能的参考实现。
AI 正在分析代码…
评论加载中...