构建基于 Spring 与 Nuxt 的无会话 WebAuthn 认证架构


在构建需要横向扩展的现代 Web 应用时,维持后端的无状态(Stateless)是一项核心原则。然而,像 WebAuthn 这种涉及多步客户端-服务器交互的复杂认证协议,似乎天然地倾向于使用服务端会话(Session)来暂存握手过程中的状态,例如那个至关重要的、一次性的 challenge 值。这直接与无状态架构的目标相悖。在真实项目中,任何依赖 HttpSession 的实现都会立即给集群部署、负载均衡和弹性伸缩带来不必要的复杂性。我们面临的第一个技术痛点,就是如何在 Spring Boot 后端实现一个完全无会话的 WebAuthn 流程,同时保证其安全性。

初步的构想是,将本应由服务端会话持有的状态,转移到客户端。但这不能简单地交给客户端保管,因为那样状态可能被篡改。一个可行的方案是,在第一步(请求注册/登录选项)时,服务端生成包含 challenge 的选项对象,然后将其整体(或其核心部分)通过某种防篡改的机制传递给客户端,待客户端完成与认证器(如指纹、安全密钥)的交互后,再将这个状态原封不动地带回来进行校验。这种方式将状态的存储责任“外包”给了客户端的单次请求-响应周期,从而解放了服务端。

技术选型上,后端采用 Spring Boot 3 与 Spring Security 6,它们提供了坚实的 Web 服务基础。为了处理 WebAuthn 的复杂密码学逻辑,我们引入 Yubico 的 webauthn-server-core 库,这是一个经过实战检验的 Relying Party (RP) 实现。前端则选择 Nuxt.js 3,其基于 Vue 3 的组合式 API (Composition API) 和强大的文件系统路由,非常适合构建逻辑清晰、可维护的单页应用。特别是,我们可以将 WebAuthn 的前端交互逻辑封装成一个可复用的 composable,极大提升代码的整洁度。

后端架构:设计无会话的 Relying Party 服务

我们的核心是设计一套不依赖 HttpSession 的 RESTful API。这意味着每个请求都必须是自包含的,服务端不保留任何关于上一个请求的记忆。

1. 依赖与配置

首先,在 Spring Boot 项目的 pom.xml 中引入必要的依赖。

<dependencies>
    <!-- Spring Boot Starters -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- H2 Database for demonstration -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Yubico WebAuthn Server Library -->
    <dependency>
        <groupId>com.yubico</groupId>
        <artifactId>webauthn-server-core</artifactId>
        <version>2.0.0</version>
    </dependency>

    <!-- Lombok for boilerplate code reduction -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

接下来是配置 RelyingParty 实例。这是与 WebAuthn 交互的中心点,它定义了我们的应用(即 Relying Party)的身份。

package com.example.webauthn.config;

import com.yubico.webauthn.RelyingParty;
import com.yubico.webauthn.data.RelyingPartyIdentity;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Set;

@Configuration
public class WebAuthnConfig {

    @Bean
    public RelyingParty relyingParty() {
        // 配置我们的应用身份信息
        // ID 必须是网站的有效域名
        // Name 是向用户展示的应用名称
        RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder()
            .id("localhost") // 在生产环境中, 这必须是你的域名, 例如 "example.com"
            .name("Spring Nuxt WebAuthn Demo")
            .build();

        return RelyingParty.builder()
            .identity(rpIdentity)
            .origins(Set.of("http://localhost:3000")) // 允许的前端来源, 生产环境必须是 HTTPS
            // 在真实项目中, 你需要提供一个CredentialRepository的实现来存储和检索凭证
            // 这里为了简化, 我们将在服务层处理
            .build();
    }
}

这里的坑在于,RelyingPartyIdentityidorigins 必须与前端页面的域名严格匹配,否则浏览器会出于安全考虑拒绝 WebAuthn API 调用。

2. 数据模型

我们需要持久化用户信息和他们的 WebAuthn 凭证。

package com.example.webauthn.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.HashSet;
import java.util.Set;

@Entity
@Getter
@Setter
@NoArgsConstructor
public class WebAuthnUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;
    
    // 用于 WebAuthn 的唯一用户句柄, 通常是字节数组
    @Lob
    @Column(unique = true, nullable = false)
    private byte[] userHandle;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    private Set<Credential> credentials = new HashSet<>();

    public WebAuthnUser(String username, byte[] userHandle) {
        this.username = username;
        this.userHandle = userHandle;
    }
}
package com.example.webauthn.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Credential {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Lob
    @Column(nullable = false)
    private byte[] credentialId;

    @Lob
    @Column(nullable = false)
    private byte[] publicKeyCose;

    private long signatureCount;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private WebAuthnUser user;

    public Credential(byte[] credentialId, byte[] publicKeyCose, long signatureCount, WebAuthnUser user) {
        this.credentialId = credentialId;
        this.publicKeyCose = publicKeyCose;
        this.signatureCount = signatureCount;
        this.user = user;
    }
}

userHandle 是用户的唯一标识符,对于 WebAuthn 协议至关重要。publicKeyCose 存储了用户认证器生成的公钥。

3. 核心服务层实现

WebAuthnService 将封装所有与 WebAuthn 协议相关的业务逻辑。注意这里我们如何处理 challenge 以避免会话。

package com.example.webauthn.service;

import com.example.webauthn.domain.Credential;
import com.example.webauthn.domain.WebAuthnUser;
import com.example.webauthn.repository.CredentialRepository;
import com.example.webauthn.repository.UserRepository;
import com.yubico.webauthn.*;
import com.yubico.webauthn.data.*;
import com.yubico.webauthn.exception.RegistrationFailedException;
import com.yubico.webauthn.exception.VerificationFailedException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.security.SecureRandom;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@Slf4j
public class WebAuthnService {

    private final RelyingParty relyingParty;
    private final UserRepository userRepository;
    private final CredentialRepository credentialRepository;
    private final SecureRandom random = new SecureRandom();

    public WebAuthnService(RelyingParty relyingParty, UserRepository userRepository, CredentialRepository credentialRepository) {
        this.relyingParty = relyingParty;
        this.userRepository = userRepository;
        this.credentialRepository = credentialRepository;
    }
    
    // --- 注册流程 ---

    public PublicKeyCredentialCreationOptions startRegistration(String username) {
        WebAuthnUser user = userRepository.findByUsername(username).orElseGet(() -> {
            byte[] userHandle = new byte[64];
            random.nextBytes(userHandle);
            return new WebAuthnUser(username, userHandle);
        });

        if (user.getId() == null) {
            userRepository.save(user);
        }

        StartRegistrationOptions options = StartRegistrationOptions.builder()
            .user(UserIdentity.builder()
                .name(username)
                .displayName(username)
                .id(ByteArray.of(user.getUserHandle()))
                .build())
            // 排除用户已经注册过的凭证,防止重复注册
            .excludeCredentials(user.getCredentials().stream()
                .map(cred -> PublicKeyCredentialDescriptor.builder()
                    .id(ByteArray.of(cred.getCredentialId()))
                    .build())
                .collect(Collectors.toSet()))
            .build();
        
        // 生成的 RegistrationOptions 中包含了 challenge, 我们将其直接返回给前端
        // 前端完成设备交互后, 会将这个 options 对象和设备返回的数据一起发回来
        return relyingParty.startRegistration(options);
    }

    @Transactional
    public boolean finishRegistration(String registrationResponseJson, String creationOptionsJson) {
        try {
            PublicKeyCredentialCreationOptions creationOptions = PublicKeyCredentialCreationOptions.fromJson(creationOptionsJson);
            WebAuthnUser user = userRepository.findByUsername(creationOptions.getUser().getName())
                .orElseThrow(() -> new RegistrationFailedException(new ByteArray(new byte[0]), "User not found."));

            RegistrationResult result = relyingParty.finishRegistration(FinishRegistrationOptions.builder()
                .request(creationOptions)
                .response(PublicKeyCredential.parseRegistrationResponseJson(registrationResponseJson))
                .build());

            if (result.isSuccess()) {
                Credential credential = new Credential(
                    result.getKeyId().getId().getBytes(),
                    result.getPublicKeyCose().getBytes(),
                    result.getSignatureCount(),
                    user
                );
                credentialRepository.save(credential);
                log.info("Registration successful for user: {}", user.getUsername());
                return true;
            }
        } catch (Exception e) {
            log.error("Registration failed", e);
        }
        return false;
    }

    // --- 登录流程 ---

    public AssertionRequest startLogin(String username) {
        WebAuthnUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new IllegalArgumentException("User not found: " + username));
        
        List<PublicKeyCredentialDescriptor> allowedCredentials = user.getCredentials().stream()
                .map(cred -> PublicKeyCredentialDescriptor.builder().id(new ByteArray(cred.getCredentialId())).build())
                .collect(Collectors.toList());

        StartAssertionOptions options = StartAssertionOptions.builder()
            .username(Optional.of(username))
            // 也可以不提供 username,而只提供 allowedCredentials,以支持无密码登录
            // .allowedCredentials(Optional.of(allowedCredentials))
            .build();

        // AssertionRequest 包含了 challenge, 同样直接返回给前端
        return relyingParty.startAssertion(options);
    }

    @Transactional
    public boolean finishLogin(String assertionResponseJson, String assertionRequestJson) {
        try {
            AssertionRequest request = AssertionRequest.fromJson(assertionRequestJson);
            PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> response = 
                PublicKeyCredential.parseAssertionResponseJson(assertionResponseJson);
            
            // 这里的 credentialRepository 是我们自己实现的,用于根据 credentialId 查找凭证
            com.yubico.webauthn.CredentialRepository ourRepo = (credentialId) ->
                credentialRepository.findByCredentialId(credentialId.getBytes()).stream()
                    .map(cred -> RegisteredCredential.builder()
                        .credentialId(new ByteArray(cred.getCredentialId()))
                        .userHandle(new ByteArray(cred.getUser().getUserHandle()))
                        .publicKeyCose(new ByteArray(cred.getPublicKeyCose()))
                        .signatureCount(cred.getSignatureCount())
                        .build())
                    .collect(Collectors.toSet());

            AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder()
                .request(request)
                .response(response)
                .credentialRepository(ourRepo)
                .build());

            if (result.isSuccess()) {
                // 更新签名计数器,这是一个重要的安全措施,防止凭证被克隆
                credentialRepository.updateSignatureCount(result.getSignatureCount(), result.getCredential().getCredentialId().getBytes());
                log.info("Login successful for user: {}", result.getUsername());
                return true;
            }
        } catch (Exception e) {
            log.error("Login failed", e);
        }
        return false;
    }
}

关键点在于 finishRegistrationfinishLogin 方法。它们都接收两个参数:一个是来自前端认证器交互的结果 registrationResponseJson / assertionResponseJson,另一个是当初我们发给前端的选项对象 creationOptionsJson / assertionRequestJson。服务端通过反序列化后者,来恢复握手上下文(尤其是 challenge),从而完成验证,全程无需 HttpSession

4. Controller 层

Controller 层非常薄,只是简单地将 HTTP 请求路由到 Service 层。

package com.example.webauthn.controller;

import com.example.webauthn.service.WebAuthnService;
import com.yubico.webauthn.AssertionRequest;
import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/webauthn")
public class WebAuthnController {

    private final WebAuthnService webAuthnService;

    public WebAuthnController(WebAuthnService webAuthnService) {
        this.webAuthnService = webAuthnService;
    }

    @PostMapping("/register/start")
    public ResponseEntity<String> startRegistration(@RequestBody Map<String, String> body) {
        String username = body.get("username");
        PublicKeyCredentialCreationOptions options = webAuthnService.startRegistration(username);
        return ResponseEntity.ok(options.toJson());
    }

    @PostMapping("/register/finish")
    public ResponseEntity<Map<String, Boolean>> finishRegistration(@RequestBody Map<String, String> body) {
        boolean success = webAuthnService.finishRegistration(body.get("response"), body.get("options"));
        return ResponseEntity.ok(Map.of("success", success));
    }

    @PostMapping("/login/start")
    public ResponseEntity<String> startLogin(@RequestBody Map<String, String> body) {
        String username = body.get("username");
        AssertionRequest request = webAuthnService.startLogin(username);
        return ResponseEntity.ok(request.toJson());
    }

    @PostMapping("/login/finish")
    public ResponseEntity<Map<String, Boolean>> finishLogin(@RequestBody Map<String, String> body) {
        boolean success = webAuthnService.finishLogin(body.get("response"), body.get("request"));
        return ResponseEntity.ok(Map.of("success", success));
    }
}

至此,一个完全无会话的 Spring Boot WebAuthn 后端已经准备就绪。

前端实现:Nuxt.js 与 WebAuthn API 的优雅结合

前端的挑战在于处理与浏览器 CredentialsContainer API (navigator.credentials) 的复杂异步交互,并将其与我们的后端 API 流程串联起来。

1. 依赖安装

为了简化与 WebAuthn API 的交互,我们使用 @simplewebauthn/browser 库。

npm install @simplewebauthn/browser

2. 创建 Composable (useWebAuthn.ts)

我们将所有 WebAuthn 逻辑封装在一个 Nuxt composable 中,这使得在任何组件中调用都变得非常简单。

// composables/useWebAuthn.ts
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';

export const useWebAuthn = () => {
  const register = async (username: string) => {
    try {
      // 1. 从后端获取注册选项
      const optionsResponse = await $fetch('/api/webauthn/register/start', {
        method: 'POST',
        body: { username },
      });

      // 2. 调用浏览器 API, 弹出系统UI让用户进行生物识别或插入安全密钥
      // 这个过程是异步的, 用户可能会取消
      const attestation = await startRegistration(optionsResponse);

      // 3. 将认证器返回的结果和我们从服务器收到的选项一起发回后端进行验证
      const verificationResponse = await $fetch('/api/webauthn/register/finish', {
        method: 'POST',
        body: {
          response: attestation,
          options: optionsResponse, // 将原始 options 回传,实现无状态
        },
      });

      if (verificationResponse.success) {
        alert('Registration successful!');
        return true;
      } else {
        throw new Error('Registration verification failed on server.');
      }
    } catch (error: any) {
      // 必须处理用户取消操作等常见错误
      if (error.name === 'InvalidStateError') {
        console.warn('Authenticator was probably already registered by user');
      } else {
        console.error('Registration failed:', error);
      }
      alert(`Registration failed: ${error.message || 'Unknown error'}`);
      return false;
    }
  };

  const login = async (username: string) => {
    try {
      // 1. 从后端获取登录选项 (AssertionRequest)
      const requestResponse = await $fetch('/api/webauthn/login/start', {
        method: 'POST',
        body: { username },
      });

      // 2. 调用浏览器 API 进行认证
      const assertion = await startAuthentication(requestResponse);

      // 3. 将认证结果和原始请求一起发回后端验证
      const verificationResponse = await $fetch('/api/webauthn/login/finish', {
        method: 'POST',
        body: {
          response: assertion,
          request: requestResponse, // 回传原始 request,实现无状态
        },
      });

      if (verificationResponse.success) {
        alert('Login successful!');
        return true;
      } else {
        throw new Error('Login verification failed on server.');
      }
    } catch (error: any) {
      console.error('Login failed:', error);
      alert(`Login failed: ${error.message || 'Unknown error'}`);
      return false;
    }
  };

  return { register, login };
};

注意在 Nuxt 3 中,我们需要配置代理将 /api 请求转发到 Spring Boot 后端。在 nuxt.config.ts 中添加:

export default defineNuxtConfig({
  // ...
  vite: {
    server: {
      proxy: {
        '/api': {
          target: 'http://localhost:8080',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/api/, ''),
        },
      },
    },
  },
});

3. UI 组件 (app.vue)

现在,我们可以在页面组件中轻松使用这个 composable。

<template>
  <div class="container">
    <h1>Spring + Nuxt: Stateless WebAuthn</h1>
    <div class="form-section">
      <input v-model="username" type="text" placeholder="Enter username" :disabled="loading" />
      <div class="button-group">
        <button @click="handleRegister" :disabled="loading || !username">
          {{ loading ? 'Registering...' : 'Register with Passkey' }}
        </button>
        <button @click="handleLogin" :disabled="loading || !username">
          {{ loading ? 'Logging in...' : 'Login with Passkey' }}
        </button>
      </div>
    </div>
    <div v-if="message" class="message" :class="{ 'error': isError }">
      {{ message }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const username = ref('');
const loading = ref(false);
const message = ref('');
const isError = ref(false);

const { register, login } = useWebAuthn();

const handleRegister = async () => {
  if (!username.value) {
    setMessage('Username is required.', true);
    return;
  }
  loading.value = true;
  setMessage('');
  const success = await register(username.value);
  if (success) {
    setMessage('Registration successful!', false);
  } else {
    setMessage('Registration failed. Check console for details.', true);
  }
  loading.value = false;
};

const handleLogin = async () => {
  if (!username.value) {
    setMessage('Username is required.', true);
    return;
  }
  loading.value = true;
  setMessage('');
  const success = await login(username.value);
  if (success) {
    setMessage('Login successful!', false);
  } else {
    setMessage('Login failed. Check console for details.', true);
  }
  loading.value = false;
};

const setMessage = (msg: string, error: boolean = false) => {
  message.value = msg;
  isError.value = error;
};
</script>

<style scoped>
/* 省略一些基本样式... */
.container { max-width: 500px; margin: 50px auto; padding: 20px; text-align: center; }
.form-section { margin-top: 20px; }
input { width: 100%; padding: 10px; margin-bottom: 10px; }
.button-group { display: flex; gap: 10px; }
button { flex-grow: 1; padding: 10px; }
.message { margin-top: 20px; }
.error { color: red; }
</style>

这个简单的 UI 展示了如何驱动整个无密码注册和登录流程。

流程可视化

为了更清晰地理解整个交互过程,我们可以用 Mermaid 图来描述注册流程。

sequenceDiagram
    participant User
    participant NuxtApp as Nuxt.js App (Frontend)
    participant SpringAPI as Spring Boot API (Backend)
    participant Authenticator as Hardware/Software Authenticator

    User->>NuxtApp: 1. Enters username, clicks "Register"
    NuxtApp->>SpringAPI: 2. POST /webauthn/register/start (username)
    SpringAPI->>SpringAPI: 3. Generate UserIdentity & Challenge
    SpringAPI-->>NuxtApp: 4. Returns PublicKeyCredentialCreationOptions (contains challenge)
    NuxtApp->>Authenticator: 5. Calls navigator.credentials.create(options)
    Authenticator-->>User: 6. Prompts for biometric/PIN
    User-->>Authenticator: 7. Provides authentication
    Authenticator->>Authenticator: 8. Creates new public/private key pair
    Authenticator-->>NuxtApp: 9. Returns Attestation object (contains public key)
    NuxtApp->>SpringAPI: 10. POST /webauthn/register/finish (Attestation, original Options)
    SpringAPI->>SpringAPI: 11. Verifies challenge, signature, and other data
    SpringAPI->>SpringAPI: 12. Persists new Credential (public key, credentialId)
    SpringAPI-->>NuxtApp: 13. Returns { "success": true }
    NuxtApp-->>User: 14. Displays success message

这个图清晰地展示了 PublicKeyCredentialCreationOptions 对象(包含了 challenge)是如何从后端传递到前端,再原样返回后端进行验证的,从而实现了服务端的无状态。

当前方案的局限性与未来展望

我们成功地构建了一个完全无会ip会话的 WebAuthn 认证流程,它满足了现代可扩展后端架构的要求。然而,这个实现仍有可以深化的地方。首先,我们没有详细处理凭证的Attestation。在对安全要求极高的场景下,Relying Party需要验证Attestation statement来确认认证器的型号、来源等信息是否可信。Yubico的库对此提供了支持,但在真实项目中需要仔细配置信任锚(Trust Anchors)。

其次,为了简化,我们没有实现登录成功后的会话管理,例如生成 JWT。在生产环境中,finishLogin 成功后,后端应生成一个无状态的 token(如 JWT),返回给前端。前端在后续请求中携带此 token,并通过 Spring Security 的过滤器链进行验证,这才是完整的无状态认证授权闭环。

最后,WebAuthn/FIDO2 标准本身在不断演进。一个重要的方向是“可发现凭证”(Discoverable Credentials)或称“驻留密钥”(Resident Keys),也就是我们常说的 Passkeys。它允许用户在未输入用户名的情况下直接发起登录,认证器能直接告诉网站“我是哪个用户”。我们当前的 startLogin 实现依赖于用户名,要支持 Passkeys,需要对登录流程的起点进行改造,允许在不提供 username 的情况下调用 relyingParty.startAssertion。这无疑是提升用户体验的下一个迭代方向。


  目录