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
相关产品推荐
相关产品推荐

