You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Spring Security自定义WebAuthn登录与注册页面时客户端WebAuthn逻辑实现及复用代码需求咨询

Spring Security自定义WebAuthn登录与注册页面时客户端WebAuthn逻辑实现及复用代码需求咨询

你提的这个问题确实戳中了很多用Spring Security做自定义WebAuthn页面开发者的痛点,我来给你梳理清楚:

核心结论:需要自行适配客户端逻辑,但不用从零实现

当你禁用默认WebAuthn注册页、配置自定义登录/注册页面后,Spring Security确实不会再自动提供spring-security-webauthn.js这个静态资源——因为它原本是和官方默认页面绑定的。不过你完全不用从头造轮子,参考官方的核心逻辑,就能快速实现一个可复用的客户端工具类,既减少重复劳动,也能规避很多容易踩的安全坑。

为什么官方JS不可用?

简单来说,spring-security-webauthn.js是和Spring Security内置的WebAuthn页面强绑定的,当你禁用默认页面后,Spring Security的资源映射里不会再包含这个JS文件,所以前端自然访问不到。

可复用的客户端WebAuthn逻辑实现

我整理了一个通用的WebAuthn客户端工具类,已经处理了WebAuthn流程中最容易出错的数据格式转换浏览器API调用后端交互逻辑,你可以直接拿去适配自己的页面:

class WebAuthnClient {
    // 发起Passkey注册流程:获取后端选项 → 调用WebAuthn API → 提交结果
    async startRegistration(registrationSubmitUrl, registrationOptionsUrl) {
        // 1. 从后端获取注册挑战参数
        const optionsResponse = await fetch(registrationOptionsUrl, {
            method: 'GET',
            credentials: 'include'
        });
        const publicKeyOptions = await optionsResponse.json();
        // 把后端返回的base64url格式参数转成浏览器API需要的Uint8Array
        this.transformPublicKeyOptions(publicKeyOptions);

        // 2. 调用浏览器WebAuthn注册API
        const newCredential = await navigator.credentials.create({ publicKey: publicKeyOptions });

        // 3. 把API返回的结果转成后端能解析的base64url格式
        const registrationPayload = {
            id: newCredential.id,
            rawId: this.arrayBufferToBase64Url(newCredential.rawId),
            type: newCredential.type,
            response: {
                clientDataJSON: this.arrayBufferToBase64Url(newCredential.response.clientDataJSON),
                attestationObject: this.arrayBufferToBase64Url(newCredential.response.attestationObject)
            }
        };

        // 4. 提交给后端完成注册
        await fetch(registrationSubmitUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'include',
            body: JSON.stringify(registrationPayload)
        });
    }

    // 发起Passkey登录流程:逻辑和注册类似
    async startAuthentication(authSubmitUrl, authOptionsUrl) {
        // 1. 获取登录挑战参数
        const optionsResponse = await fetch(authOptionsUrl, {
            method: 'GET',
            credentials: 'include'
        });
        const publicKeyOptions = await optionsResponse.json();
        this.transformPublicKeyOptions(publicKeyOptions);

        // 2. 调用浏览器WebAuthn登录API
        const credential = await navigator.credentials.get({ publicKey: publicKeyOptions });

        // 3. 转换结果格式
        const authPayload = {
            id: credential.id,
            rawId: this.arrayBufferToBase64Url(credential.rawId),
            type: credential.type,
            response: {
                clientDataJSON: this.arrayBufferToBase64Url(credential.response.clientDataJSON),
                authenticatorData: this.arrayBufferToBase64Url(credential.response.authenticatorData),
                signature: this.arrayBufferToBase64Url(credential.response.signature),
                userHandle: credential.response.userHandle ? this.arrayBufferToBase64Url(credential.response.userHandle) : null
            }
        };

        // 4. 提交给后端完成登录
        await fetch(authSubmitUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'include',
            body: JSON.stringify(authPayload)
        });
    }

    // 转换后端返回的PublicKey选项为浏览器可识别的格式
    transformPublicKeyOptions(publicKey) {
        // 转换挑战参数
        publicKey.challenge = this.base64UrlToArrayBuffer(publicKey.challenge);
        // 转换用户ID(注册时用)
        if (publicKey.user) {
            publicKey.user.id = this.base64UrlToArrayBuffer(publicKey.user.id);
        }
        // 转换允许的凭据列表(登录时用)
        if (publicKey.allowCredentials) {
            publicKey.allowCredentials = publicKey.allowCredentials.map(cred => {
                cred.id = this.base64UrlToArrayBuffer(cred.id);
                return cred;
            });
        }
    }

    // ArrayBuffer转base64url(后端需要的格式)
    arrayBufferToBase64Url(buffer) {
        return btoa(String.fromCharCode(...new Uint8Array(buffer)))
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
            .replace(/=/g, '');
    }

    // base64url转ArrayBuffer(浏览器API需要的格式)
    base64UrlToArrayBuffer(base64Url) {
        const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
        const padded = base64.padEnd(base64.length + ((4 - base64.length % 4) % 4), '=');
        const binaryStr = atob(padded);
        const buffer = new ArrayBuffer(binaryStr.length);
        const view = new Uint8Array(buffer);
        for (let i = 0; i < binaryStr.length; i++) {
            view[i] = binaryStr.charCodeAt(i);
        }
        return buffer;
    }
}

怎么在自定义页面里用这个工具类?

只需要在你的登录/注册页面里初始化这个类,给按钮绑定点击事件就行:

// 初始化客户端实例
const webAuthn = new WebAuthnClient();

// 给注册按钮绑定事件
document.getElementById('register-passkey-btn').addEventListener('click', async () => {
    try {
        // 这里的URL是Spring Security默认提供的WebAuthn端点
        await webAuthn.startRegistration('/webauthn/register', '/webauthn/register/options');
        // 注册成功后的逻辑,比如跳转到首页
        window.location.href = '/';
    } catch (err) {
        console.error('Passkey注册失败:', err);
        alert('注册失败,请检查后重试');
    }
});

// 给登录按钮绑定事件
document.getElementById('login-passkey-btn').addEventListener('click', async () => {
    try {
        await webAuthn.startAuthentication('/webauthn/authenticate', '/webauthn/authenticate/options');
        // 登录成功后的逻辑
        window.location.href = '/';
    } catch (err) {
        console.error('Passkey登录失败:', err);
        alert('登录失败,请检查后重试');
    }
});

关于官方复用代码的需求

完全同意你的看法——如果官方能提供一个和默认页面解耦的独立WebAuthn客户端工具库,那就太方便了!既能避免每个开发者重复实现,也能减少因为不熟悉WebAuthn细节导致的安全漏洞。不过目前官方还没有提供这样的独立库,所以用上面的复用代码或者基于官方spring-security-webauthn.js的源码抽离逻辑,是最稳妥的方案。

额外提醒

  • 上面的工具类已经处理了WebAuthn流程中最容易出错的格式转换问题,这也是很多开发者踩坑的地方
  • 后端的/webauthn/register/options/webauthn/register/webauthn/authenticate/options/webauthn/authenticate都是Spring Security默认提供的端点,只要你配置了webAuthn()就会自动生效,不需要自己实现
  • 你可以根据自己的页面需求扩展这个工具类,比如添加加载状态、更友好的错误提示等

火山引擎 最新活动