feat(auth): 实现通行密钥验证功能

- 添加了对通行密钥断言验证的支持- 更新了用户凭证实体类以支持传输方式存储
- 修改了凭证仓库实现,以正确处理认证器传输方式
- 调整了授权服务实现,完善注册与验证流程
- 配置了Sa-Token拦截器,排除通行密钥相关路径
- 引入必要的异常处理和JSON序列化支持
- 增加了对凭证签名计数更新的支持
- 优化了缓存键的管理逻辑
This commit is contained in:
Grand-cocoa 2025-11-10 19:10:43 +08:00
parent ecfeb0e9f5
commit 7111ad07d6
6 changed files with 82 additions and 18 deletions

View File

@ -3,6 +3,7 @@ package com.kane.animo.auth.controller;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.kane.animo.auth.service.PasskeyAuthorizationService; import com.kane.animo.auth.service.PasskeyAuthorizationService;
import com.kane.animo.model.R; import com.kane.animo.model.R;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException; import com.yubico.webauthn.exception.RegistrationFailedException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -47,7 +48,7 @@ public class PasskeyAuthorizationController {
* @return 验证参数 * @return 验证参数
*/ */
@GetMapping("/assertion/options") @GetMapping("/assertion/options")
public R<String> getPasskeyAssertionOptions(HttpServletRequest httpServletRequest) { public R<String> getPasskeyAssertionOptions(HttpServletRequest httpServletRequest) throws JsonProcessingException {
return R.success(service.startPasskeyAssertion(httpServletRequest.getSession().getId())); return R.success(service.startPasskeyAssertion(httpServletRequest.getSession().getId()));
} }
@ -57,9 +58,9 @@ public class PasskeyAuthorizationController {
* @param credential 凭证 * @param credential 凭证
*/ */
@PostMapping("/assertion") @PostMapping("/assertion")
public R<Void> verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) { public R<String> verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) throws IOException, AssertionFailedException {
service.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential); String s = service.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential);
return R.success(); return R.success(s);
} }
} }

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import lombok.experimental.Accessors;
/** /**
* 用户凭证 * 用户凭证
@ -11,6 +12,7 @@ import lombok.Data;
* @since 2025/11/7 16:57 * @since 2025/11/7 16:57
*/ */
@Data @Data
@Accessors(chain = true)
@TableName("user_credential") @TableName("user_credential")
public class UserCredential { public class UserCredential {
/** /**
@ -30,6 +32,10 @@ public class UserCredential {
* 身份标识 * 身份标识
*/ */
private String identity; private String identity;
/**
* 传输方式
*/
private String transports;
/** /**
* 删除标识 * 删除标识
*/ */

View File

@ -1,6 +1,7 @@
package com.kane.animo.auth.service; package com.kane.animo.auth.service;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException; import com.yubico.webauthn.exception.RegistrationFailedException;
import java.io.IOException; import java.io.IOException;
@ -32,7 +33,7 @@ public interface PasskeyAuthorizationService {
* @param id 登录id * @param id 登录id
* @return 验证参数 * @return 验证参数
*/ */
String startPasskeyAssertion(String id); String startPasskeyAssertion(String id) throws JsonProcessingException;
/** /**
* 验证通行密钥 * 验证通行密钥
@ -40,5 +41,5 @@ public interface PasskeyAuthorizationService {
* @param id 登录id * @param id 登录id
* @param credential 凭证 * @param credential 凭证
*/ */
void finishPasskeyAssertion(String id, String credential); String finishPasskeyAssertion(String id, String credential) throws IOException, AssertionFailedException;
} }

View File

@ -9,12 +9,14 @@ import com.kane.animo.auth.mapper.UserMapper;
import com.yubico.webauthn.CredentialRepository; import com.yubico.webauthn.CredentialRepository;
import com.yubico.webauthn.RegisteredCredential; import com.yubico.webauthn.RegisteredCredential;
import com.yubico.webauthn.RegistrationResult; import com.yubico.webauthn.RegistrationResult;
import com.yubico.webauthn.data.AuthenticatorTransport;
import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.data.UserIdentity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@ -53,11 +55,12 @@ public class CredentialRepositoryImpl implements CredentialRepository {
.eq(UserCredential::getUserId, one.getId()) .eq(UserCredential::getUserId, one.getId())
.list() .list()
.stream() .stream()
.map(UserCredential::getCredential) // .map(UserCredential::getCredential)
.map(x -> { .map(x -> {
RegisteredCredential credential = JSON.parseObject(x, RegisteredCredential.class); RegisteredCredential credential = JSON.parseObject(x.getCredential(), RegisteredCredential.class);
return PublicKeyCredentialDescriptor.builder() return PublicKeyCredentialDescriptor.builder()
.id(credential.getCredentialId()) .id(credential.getCredentialId())
.transports(new HashSet<>(JSON.parseArray(x.getTransports(), AuthenticatorTransport.class)))
.build(); .build();
}).collect(Collectors.toSet()); }).collect(Collectors.toSet());
} }

View File

@ -1,21 +1,28 @@
package com.kane.animo.auth.service.impl; package com.kane.animo.auth.service.impl;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.kane.animo.auth.domain.User; 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.kane.animo.auth.mapper.UserMapper;
import com.kane.animo.auth.service.PasskeyAuthorizationService; import com.kane.animo.auth.service.PasskeyAuthorizationService;
import com.kane.animo.exception.ServiceException;
import com.kane.animo.util.CacheService; import com.kane.animo.util.CacheService;
import com.yubico.webauthn.FinishRegistrationOptions; import com.yubico.webauthn.*;
import com.yubico.webauthn.RegistrationResult;
import com.yubico.webauthn.RelyingParty;
import com.yubico.webauthn.StartRegistrationOptions;
import com.yubico.webauthn.data.*; import com.yubico.webauthn.data.*;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException; import com.yubico.webauthn.exception.RegistrationFailedException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.TreeSet;
/** /**
* 通行密钥授权服务实现 * 通行密钥授权服务实现
@ -31,6 +38,9 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
@Resource @Resource
private UserMapper userMapper; private UserMapper userMapper;
@Resource
private UserCredentialMapper credentialMapper;
@Resource @Resource
private CacheService cacheService; private CacheService cacheService;
@ -77,6 +87,17 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
.request(request) .request(request)
.response(pkc) .response(pkc)
.build()); .build());
UserCredential userCredential = new UserCredential()
.setUserId(user.getId())
.setIdentity(JSON.toJSONString(request.getUser()))
.setTransports(JSON.toJSONString(result.getKeyId().getTransports().orElse(new TreeSet<>())))
.setCredential(JSON.toJSONString(RegisteredCredential.builder()
.credentialId(result.getKeyId().getId())
.userHandle(request.getUser().getId())
.publicKeyCose(result.getPublicKeyCose())
.signatureCount(result.getSignatureCount())
.build()));
credentialMapper.insert(userCredential);
cacheService.removeCache(PASSKEY_REGISTRATION_KEY + user.getId()); cacheService.removeCache(PASSKEY_REGISTRATION_KEY + user.getId());
} }
@ -87,8 +108,10 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
* @return 验证参数 * @return 验证参数
*/ */
@Override @Override
public String startPasskeyAssertion(String id) { public String startPasskeyAssertion(String id) throws JsonProcessingException {
return ""; AssertionRequest request = relyingParty.startAssertion(StartAssertionOptions.builder().build());
cacheService.setCache(PASSKEY_ASSERTION_KEY + id, request.toJson());
return request.toCredentialsGetJson();
} }
/** /**
@ -98,7 +121,35 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
* @param credential 凭证 * @param credential 凭证
*/ */
@Override @Override
public void finishPasskeyAssertion(String id, String credential) { public String finishPasskeyAssertion(String id, String credential) throws IOException, AssertionFailedException {
String cache = cacheService.getCache(PASSKEY_ASSERTION_KEY + id, String.class);
AssertionRequest request = AssertionRequest.fromJson(cache);
PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> keyCredential = PublicKeyCredential.parseAssertionResponseJson(credential);
AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder()
.request(request)
.response(keyCredential)
.build());
cacheService.removeCache(PASSKEY_ASSERTION_KEY + id);
if (!result.isSuccess()){
throw new ServiceException("验证失败");
}
User one = new LambdaQueryChainWrapper<>(userMapper)
.eq(User::getUser, result.getUsername())
.one();
List<UserCredential> list = new LambdaQueryChainWrapper<>(credentialMapper)
.eq(UserCredential::getUserId, one.getId())
.list();
for (UserCredential userCredential : list) {
RegisteredCredential parsed = JSON.parseObject(userCredential.getCredential(), RegisteredCredential.class);
if (parsed.getCredentialId().equals(result.getCredential().getCredentialId())){
RegisteredCredential build = parsed.toBuilder().signatureCount(result.getSignatureCount()).build();
new LambdaUpdateChainWrapper<>(credentialMapper)
.set(UserCredential::getCredential, JSON.toJSONString(build))
.eq(UserCredential::getId, userCredential.getId())
.update();
}
}
StpUtil.login(one.getId());
return result.getUsername();
} }
} }

View File

@ -17,7 +17,9 @@ public class SaTokenConfigurer implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> StpUtil.checkLogin())) registry.addInterceptor(new SaInterceptor(handler -> StpUtil.checkLogin()))
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/auth/login"); .excludePathPatterns("/auth/login")
// .excludePathPatterns("/auth/register"); .excludePathPatterns("/auth/register")
.excludePathPatterns("/passkey/assertion/options")
.excludePathPatterns("/passkey/assertion");
} }
} }