feat(auth): 添加通行密钥认证功能

- 新增 PasskeyAuthorizationController 控制器,提供注册与验证通行密钥的接口
- 创建 UserCredential 实体类及对应的 Mapper,用于存储用户凭证信息
- 实现 CredentialRepository 接口,支持通行密钥的查询与管理逻辑
- 新增 PasskeyAuthorizationService 接口及其实现,处理通行密钥的注册和验证流程
- 添加 PasskeyProperties 配置类,读取通行密钥相关配置项
- 引入 ehcache 缓存配置与工具类 CacheService,用于临时存储认证过程中的数据
- 配置 RelyingParty bean,支撑 WebAuthn 认证流程
- 在 pom.xml 中引入 fastjson2、webauthn-server-core 和 ehcache依赖
- 新增 application-dev.yml与 application-prod.yml 配置文件,区分环境变量
- 设置 spring.profiles.active 默认为 dev 环境
This commit is contained in:
Grand-cocoa 2025-11-07 18:28:10 +08:00
parent 6ceab3cf86
commit ecfeb0e9f5
14 changed files with 563 additions and 0 deletions

16
pom.xml
View File

@ -72,6 +72,22 @@
<artifactId>sa-token-spring-boot3-starter</artifactId> <artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version> <version>1.39.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.52</version>
</dependency>
<dependency>
<groupId>com.yubico</groupId>
<artifactId>webauthn-server-core</artifactId>
<version>2.7.0</version>
<scope>compile</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.ehcache/ehcache -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -0,0 +1,65 @@
package com.kane.animo.auth.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.kane.animo.auth.service.PasskeyAuthorizationService;
import com.kane.animo.model.R;
import com.yubico.webauthn.exception.RegistrationFailedException;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
/**
* 通行密钥
* @author Kane
* @since 2025/11/7 17:40
*/
@RestController
@RequestMapping("/passkey")
public class PasskeyAuthorizationController {
@Resource
private PasskeyAuthorizationService service;
/**
* 获取通行密钥创建参数
* @return 创建参数
*/
@GetMapping("/registration/options")
public R<String> getPasskeyRegistrationOptions() throws JsonProcessingException {
return R.success(service.startPasskeyRegistration());
}
/**
* 验证通行密钥
* @param credential 凭证
*/
@PostMapping("/registration")
public R<Void> verifyPasskeyRegistration(@RequestBody String credential) throws RegistrationFailedException, IOException {
service.finishPasskeyRegistration(credential);
return R.success();
}
/**
* 获取通行密钥验证参数
* @param httpServletRequest 请求
* @return 验证参数
*/
@GetMapping("/assertion/options")
public R<String> getPasskeyAssertionOptions(HttpServletRequest httpServletRequest) {
return R.success(service.startPasskeyAssertion(httpServletRequest.getSession().getId()));
}
/**
* 验证通行密钥
* @param httpServletRequest 请求
* @param credential 凭证
*/
@PostMapping("/assertion")
public R<Void> verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) {
service.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential);
return R.success();
}
}

View File

@ -0,0 +1,38 @@
package com.kane.animo.auth.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 用户凭证
* @author Kane
* @since 2025/11/7 16:57
*/
@Data
@TableName("user_credential")
public class UserCredential {
/**
* ID
*/
@TableId
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 用户凭证
*/
private String credential;
/**
* 身份标识
*/
private String identity;
/**
* 删除标识
*/
@TableLogic
private Integer delFlag;
}

View File

@ -0,0 +1,14 @@
package com.kane.animo.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kane.animo.auth.domain.UserCredential;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户凭证持久层
* @author Kane
* @since 2025/11/7 17:00
*/
@Mapper
public interface UserCredentialMapper extends BaseMapper<UserCredential> {
}

View File

@ -0,0 +1,44 @@
package com.kane.animo.auth.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.yubico.webauthn.exception.RegistrationFailedException;
import java.io.IOException;
/**
* 通行密钥授权服务
*
* @author Kane
* @since 2025/11/7 17:46
*/
public interface PasskeyAuthorizationService {
/**
* 获取通行密钥创建参数
*
* @return 创建参数
*/
String startPasskeyRegistration() throws JsonProcessingException;
/**
* 验证通行密钥
*
* @param credential 凭证
*/
void finishPasskeyRegistration(String credential) throws IOException, RegistrationFailedException;
/**
* 获取通行密钥验证参数
*
* @param id 登录id
* @return 验证参数
*/
String startPasskeyAssertion(String id);
/**
* 验证通行密钥
*
* @param id 登录id
* @param credential 凭证
*/
void finishPasskeyAssertion(String id, String credential);
}

View File

@ -0,0 +1,160 @@
package com.kane.animo.auth.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.kane.animo.auth.domain.User;
import com.kane.animo.auth.domain.UserCredential;
import com.kane.animo.auth.mapper.UserCredentialMapper;
import com.kane.animo.auth.mapper.UserMapper;
import com.yubico.webauthn.CredentialRepository;
import com.yubico.webauthn.RegisteredCredential;
import com.yubico.webauthn.RegistrationResult;
import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
import com.yubico.webauthn.data.UserIdentity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 凭证存储
* @author Kane
* @since 2025/11/7 17:04
*/
@Service
public class CredentialRepositoryImpl implements CredentialRepository {
@Resource
private UserMapper userMapper;
@Resource
private UserCredentialMapper credentialMapper;
/**
* Get the credential IDs of all credentials registered to the user with the given username.
*
* <p>After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method
* returns a value suitable for inclusion in this set.
*
* <p>Implementations of this method MUST NOT return null.
*
* @param username
*/
@Override
public Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username) {
User one = new LambdaQueryChainWrapper<>(userMapper)
.eq(User::getUser, username)
.one();
return new LambdaQueryChainWrapper<>(credentialMapper)
.eq(UserCredential::getUserId, one.getId())
.list()
.stream()
.map(UserCredential::getCredential)
.map(x -> {
RegisteredCredential credential = JSON.parseObject(x, RegisteredCredential.class);
return PublicKeyCredentialDescriptor.builder()
.id(credential.getCredentialId())
.build();
}).collect(Collectors.toSet());
}
/**
* Get the user handle corresponding to the given username - the inverse of {@link
* #getUsernameForUserHandle(ByteArray)}.
*
* <p>Used to look up the user handle based on the username, for authentication ceremonies where
* the username is already given.
*
* <p>Implementations of this method MUST NOT return null.
*
* @param username
*/
@Override
public Optional<ByteArray> getUserHandleForUsername(String username) {
User one = new LambdaQueryChainWrapper<>(userMapper)
.eq(User::getUser, username)
.one();
return new LambdaQueryChainWrapper<>(credentialMapper)
.eq(UserCredential::getUserId, one.getId())
.list()
.stream().findAny()
.map(x -> JSON.parseObject(x.getIdentity(), UserIdentity.class).getId());
}
/**
* Get the username corresponding to the given user handle - the inverse of {@link
* #getUserHandleForUsername(String)}.
*
* <p>Used to look up the username based on the user handle, for username-less authentication
* ceremonies.
*
* <p>Implementations of this method MUST NOT return null.
*
* @param userHandle
*/
@Override
public Optional<String> getUsernameForUserHandle(ByteArray userHandle) {
Set<Long> userIds = credentialMapper.selectList(null)
.stream().filter(x -> {
UserIdentity userIdentity = JSON.parseObject(x.getIdentity(), UserIdentity.class);
return userIdentity.getId().equals(userHandle);
}).map(UserCredential::getUserId).collect(Collectors.toSet());
if (userIds.isEmpty()){
return Optional.empty();
}
return Optional.ofNullable(new LambdaQueryChainWrapper<>(userMapper)
.eq(User::getId, userIds.iterator().next())
.one()
.getUser());
}
/**
* Look up the public key and stored signature count for the given credential registered to the
* given user.
*
* <p>The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read
* directly from a database or assembled from other components.
*
* <p>Implementations of this method MUST NOT return null.
*
* @param credentialId
* @param userHandle
*/
@Override
public Optional<RegisteredCredential> lookup(ByteArray credentialId, ByteArray userHandle) {
List<UserCredential> userCredentials = credentialMapper.selectList(null);
for (UserCredential userCredential : userCredentials) {
RegisteredCredential credential = JSON.parseObject(userCredential.getCredential(), RegisteredCredential.class);
UserIdentity identity = JSON.parseObject(userCredential.getIdentity(), UserIdentity.class);
if (credential.getCredentialId().equals(credentialId) && identity.getId().equals(userHandle)){
return Optional.of(credential);
}
}
return Optional.empty();
}
/**
* Look up all credentials with the given credential ID, regardless of what user they're
* registered to.
*
* <p>This is used to refuse registration of duplicate credential IDs. Therefore, under normal
* circumstances this method should only return zero or one credential (this is an expected
* consequence, not an interface requirement).
*
* <p>Implementations of this method MUST NOT return null.
*
* @param credentialId
*/
@Override
public Set<RegisteredCredential> lookupAll(ByteArray credentialId) {
List<UserCredential> userCredentials = credentialMapper.selectList(null);
return userCredentials.stream()
.map(x -> JSON.parseObject(x.getCredential(), RegisteredCredential.class))
.filter(x -> x.getCredentialId().equals(credentialId))
.collect(Collectors.toSet());
}
}

View File

@ -0,0 +1,104 @@
package com.kane.animo.auth.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.kane.animo.auth.domain.User;
import com.kane.animo.auth.mapper.UserMapper;
import com.kane.animo.auth.service.PasskeyAuthorizationService;
import com.kane.animo.util.CacheService;
import com.yubico.webauthn.FinishRegistrationOptions;
import com.yubico.webauthn.RegistrationResult;
import com.yubico.webauthn.RelyingParty;
import com.yubico.webauthn.StartRegistrationOptions;
import com.yubico.webauthn.data.*;
import com.yubico.webauthn.exception.RegistrationFailedException;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.io.IOException;
/**
* 通行密钥授权服务实现
* @author Kane
* @since 2025/11/7 17:46
*/
@Service
public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationService {
@Resource
private RelyingParty relyingParty;
@Resource
private UserMapper userMapper;
@Resource
private CacheService cacheService;
private static final String PASSKEY_REGISTRATION_KEY = "passkey:registration:";
private static final String PASSKEY_ASSERTION_KEY = "passkey:assertion:";
/**
* 获取通行密钥创建参数
*
* @return 创建参数
*/
@Override
public String startPasskeyRegistration() throws JsonProcessingException {
User user = userMapper.selectById(StpUtil.getLoginIdAsLong());
PublicKeyCredentialCreationOptions options = relyingParty.startRegistration(StartRegistrationOptions.builder()
.user(UserIdentity.builder()
.name(user.getUser())
.displayName(user.getUser())
.id(new ByteArray(user.getId().toString().getBytes()))
.build())
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.residentKey(ResidentKeyRequirement.REQUIRED)
.build())
.build());
cacheService.setCache(PASSKEY_REGISTRATION_KEY + user.getId(), options.toJson());
return options.toCredentialsCreateJson();
}
/**
* 验证通行密钥
*
* @param credential 凭证
*/
@Override
public void finishPasskeyRegistration(String credential) throws IOException, RegistrationFailedException {
User user = userMapper.selectById(StpUtil.getLoginIdAsLong());
PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkc =
PublicKeyCredential.parseRegistrationResponseJson(credential);
PublicKeyCredentialCreationOptions request =
PublicKeyCredentialCreationOptions.fromJson(cacheService.getCache(PASSKEY_REGISTRATION_KEY + user.getId(), String.class));
RegistrationResult result = relyingParty.finishRegistration(FinishRegistrationOptions.builder()
.request(request)
.response(pkc)
.build());
cacheService.removeCache(PASSKEY_REGISTRATION_KEY + user.getId());
}
/**
* 获取通行密钥验证参数
*
* @param id 登录id
* @return 验证参数
*/
@Override
public String startPasskeyAssertion(String id) {
return "";
}
/**
* 验证通行密钥
*
* @param id 登录id
* @param credential 凭证
*/
@Override
public void finishPasskeyAssertion(String id, String credential) {
}
}

View File

@ -0,0 +1,27 @@
package com.kane.animo.config;
import org.ehcache.CacheManager;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheManagerBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;
import org.ehcache.config.units.EntryUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 缓存配置
* @author Kane
* @since 2025/11/7 18:02
*/
@Configuration
public class CacheConfigurer {
@Bean(destroyMethod = "close")
public CacheManager init(){
return CacheManagerBuilder.newCacheManagerBuilder()
.withCache("cache", CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class,
String.class,
ResourcePoolsBuilder.newResourcePoolsBuilder().heap(1000, EntryUnit.ENTRIES)))
.build(true);
}
}

View File

@ -0,0 +1,36 @@
package com.kane.animo.config;
import com.kane.animo.config.properties.PasskeyProperties;
import com.yubico.webauthn.CredentialRepository;
import com.yubico.webauthn.RelyingParty;
import com.yubico.webauthn.data.RelyingPartyIdentity;
import jakarta.annotation.Resource;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 通行密钥配置
* @author Kane
* @since 2025/11/7 17:31
*/
@Configuration
@EnableConfigurationProperties(PasskeyProperties.class)
public class PasskeyConfigurer {
@Resource
private CredentialRepository credentialRepository;
@Bean
public RelyingParty relyingParty(PasskeyProperties properties){
RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder()
.id(properties.getId())
.name(properties.getName())
.build();
return RelyingParty.builder()
.identity(rpIdentity)
.credentialRepository(credentialRepository)
.build();
}
}

View File

@ -0,0 +1,16 @@
package com.kane.animo.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 通行密钥配置参数
* @author Kane
* @since 2025/11/7 17:36
*/
@Data
@ConfigurationProperties(prefix = "passkey")
public class PasskeyProperties {
private String id;
private String name;
}

View File

@ -0,0 +1,35 @@
package com.kane.animo.util;
import com.alibaba.fastjson2.JSON;
import jakarta.annotation.Resource;
import org.ehcache.Cache;
import org.ehcache.CacheManager;
import org.springframework.stereotype.Service;
/**
* 缓存服务
* @author Kane
* @since 2025/11/7 18:11
*/
@Service
public class CacheService {
@Resource
private CacheManager manager;
private Cache<String, String> getBucket(){
return manager.getCache("cache", String.class, String.class);
}
public void setCache(String key, Object value){
getBucket().put(key, JSON.toJSONString(value));
}
public <T> T getCache(String key, Class<T> clazz){
String json = getBucket().get(key);
return JSON.parseObject(json, clazz);
}
public void removeCache(String key){
getBucket().remove(key);
}
}

View File

@ -0,0 +1,3 @@
passkey:
id: "localhost"
name: "Animo"

View File

@ -0,0 +1,3 @@
passkey:
id: "animo.alina-dace.info"
name: "Animo"

View File

@ -2,6 +2,8 @@ server:
port: 8080 port: 8080
spring: spring:
profiles:
active: dev
application: application:
name: Animo name: Animo