mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-14 08:54:04 +08:00
feat: add complete MFA support
Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
This commit is contained in:
88
web/src/utils/webauthn.ts
Normal file
88
web/src/utils/webauthn.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { WebAuthnAssertion, WebAuthnAttestation, WebAuthnLoginOptions, WebAuthnRegistrationOptions } from '../services/auth'
|
||||
|
||||
function base64UrlToBuffer(value: string) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function bufferToBase64Url(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index])
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function assertWebAuthnAvailable() {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('当前浏览器不支持通行密钥')
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebAuthnCredential(options: WebAuthnRegistrationOptions): Promise<WebAuthnAttestation> {
|
||||
assertWebAuthnAvailable()
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...options,
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64UrlToBuffer(options.user.id),
|
||||
},
|
||||
excludeCredentials: options.excludeCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToBuffer(item.id),
|
||||
})),
|
||||
},
|
||||
}) as PublicKeyCredential | null
|
||||
if (!credential) {
|
||||
throw new Error('通行密钥创建已取消')
|
||||
}
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWebAuthnAssertion(options: WebAuthnLoginOptions): Promise<WebAuthnAssertion> {
|
||||
assertWebAuthnAvailable()
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
rpId: options.rpId,
|
||||
timeout: options.timeout,
|
||||
userVerification: options.userVerification,
|
||||
allowCredentials: options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToBuffer(item.id),
|
||||
})),
|
||||
},
|
||||
}) as PublicKeyCredential | null
|
||||
if (!credential) {
|
||||
throw new Error('通行密钥验证已取消')
|
||||
}
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
||||
signature: bufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user