You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Security WebAuthn存储anonymousUser的设计意图及问题咨询

Spring Security WebAuthn 匿名用户与已认证用户存储问题解答

核心疑问梳理

  • Spring Security WebAuthn默认通过PublicKeyCredentialUserEntityRepository/MapPublicKeyCredentialUserEntityRepository同时持久化已认证用户和匿名用户,设计意图不明确
  • 采用单个JPA实体兼容传统登录与WebAuthn登录时,存储匿名用户引发业务问题,官方无明确指引
  • 对比Yubico Java WebAuthn Server Demo:不持久化匿名用户,认证时先查凭证再关联用户;注册支持已认证/未认证用户,不存在则创建,符合WebAuthn L1/L2规范
  • 复现后发现WebAuthn登录会存储匿名用户,存在以下困惑:
    • 是否需要将两类用户分开存储到不同JPA实体?
    • 是否需要自行用Redis存储匿名用户?
    • 默认Map实现的并发问题:idToUserEntity有2条记录,但usernameToUserEntity被新匿名用户覆盖,导致数据不同步

问题解答与解决方案

1. Spring Security默认设计的意图

默认实现将两类用户放在同一存储,是为了降低入门门槛,让开发者快速跑通WebAuthn流程,但这种设计仅适合测试场景,生产环境存在明显缺陷:匿名用户属于临时对象,无需长期持久化,且会污染业务用户存储。

2. 是否需要分开存储到不同JPA实体?

建议分开存储:

  • 已认证用户是长期存在的业务用户,需要持久化到JPA数据库,关联业务系统的用户信息
  • 匿名用户是WebAuthn注册流程中的临时对象,生命周期短(仅在注册流程有效),属性与业务用户差异大,无需存入持久化数据库

3. 是否需要用Redis存储匿名用户?

非常推荐用Redis存储匿名用户:

  • Redis的键值结构适合存储临时对象,可设置过期时间(比如15分钟,匹配WebAuthn注册流程的超时时间),自动清理过期的匿名用户数据
  • 避免匿名用户污染业务数据库,同时解决并发场景下的覆盖问题(用唯一UUID作为匿名用户的标识键)

4. 默认Map实现的并发问题解决

默认的MapPublicKeyCredentialUserEntityRepository是内存实现,仅用于测试,生产环境必须替换为自定义实现:

  • 为匿名用户生成唯一标识(比如UUID)作为username或单独的存储键,避免键冲突
  • 分开处理匿名用户与已认证用户的存储逻辑:
    • 匿名用户:存入Redis,设置过期时间
    • 已认证用户:存入JPA数据库
  • 使用线程安全的存储结构(如ConcurrentHashMap)或分布式缓存(Redis)替代默认的HashMap,避免并发覆盖

自定义存储实现示例思路

@Component
public class CustomPublicKeyCredentialUserEntityRepository implements PublicKeyCredentialUserEntityRepository {

    private final UserJpaRepository userJpaRepository;
    private final StringRedisTemplate redisTemplate;
    private static final String ANONYMOUS_USER_PREFIX = "webauthn:anonymous:";
    private static final Duration ANONYMOUS_USER_TTL = Duration.ofMinutes(15);

    public CustomPublicKeyCredentialUserEntityRepository(UserJpaRepository userJpaRepository, StringRedisTemplate redisTemplate) {
        this.userJpaRepository = userJpaRepository;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void saveUserEntity(PublicKeyCredentialUserEntity userEntity) {
        if (isAnonymousUser(userEntity)) {
            // 匿名用户存入Redis
            String key = ANONYMOUS_USER_PREFIX + userEntity.getId().toString();
            redisTemplate.opsForValue().set(key, JsonUtil.toJson(userEntity), ANONYMOUS_USER_TTL);
        } else {
            // 已认证用户存入JPA
            userJpaRepository.save(convertToJpaEntity(userEntity));
        }
    }

    @Override
    public Optional<PublicKeyCredentialUserEntity> findByUsername(String username) {
        // 先查Redis中的匿名用户,再查数据库中的已认证用户
        Set<String> keys = redisTemplate.keys(ANONYMOUS_USER_PREFIX + "*");
        if (keys != null) {
            for (String key : keys) {
                PublicKeyCredentialUserEntity userEntity = JsonUtil.fromJson(redisTemplate.opsForValue().get(key), PublicKeyCredentialUserEntity.class);
                if (userEntity != null && userEntity.getUsername().equals(username)) {
                    return Optional.of(userEntity);
                }
            }
        }
        return userJpaRepository.findByUsername(username)
                .map(this::convertToPublicKeyCredentialUserEntity);
    }

    @Override
    public Optional<PublicKeyCredentialUserEntity> findById(UUID id) {
        // 先查Redis中的匿名用户,再查数据库中的已认证用户
        String key = ANONYMOUS_USER_PREFIX + id.toString();
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            return Optional.of(JsonUtil.fromJson(json, PublicKeyCredentialUserEntity.class));
        }
        return userJpaRepository.findById(id)
                .map(this::convertToPublicKeyCredentialUserEntity);
    }

    private boolean isAnonymousUser(PublicKeyCredentialUserEntity userEntity) {
        // 根据业务规则判断是否为匿名用户,比如username是否为"anonymousUser"或特定前缀
        return "anonymousUser".equals(userEntity.getUsername());
    }

    // 实现JPA实体与PublicKeyCredentialUserEntity的转换逻辑
    private UserJpaEntity convertToJpaEntity(PublicKeyCredentialUserEntity userEntity) {
        // ...转换代码
    }

    private PublicKeyCredentialUserEntity convertToPublicKeyCredentialUserEntity(UserJpaEntity jpaEntity) {
        // ...转换代码
    }
}

内容的提问来源于stack exchange,提问作者Justin Cranford

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 05:23:10