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.kane.animo.auth.service.PasskeyAuthorizationService;
import com.kane.animo.model.R;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
@ -47,7 +48,7 @@ public class PasskeyAuthorizationController {
* @return 验证参数
*/
@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()));
}
@ -57,9 +58,9 @@ public class PasskeyAuthorizationController {
* @param credential 凭证
*/
@PostMapping("/assertion")
public R<Void> verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) {
service.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential);
return R.success();
public R<String> verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) throws IOException, AssertionFailedException {
String s = service.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential);
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.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 用户凭证
@ -11,6 +12,7 @@ import lombok.Data;
* @since 2025/11/7 16:57
*/
@Data
@Accessors(chain = true)
@TableName("user_credential")
public class UserCredential {
/**
@ -30,6 +32,10 @@ public class UserCredential {
* 身份标识
*/
private String identity;
/**
* 传输方式
*/
private String transports;
/**
* 删除标识
*/

View File

@ -1,6 +1,7 @@
package com.kane.animo.auth.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException;
import java.io.IOException;
@ -32,7 +33,7 @@ public interface PasskeyAuthorizationService {
* @param id 登录id
* @return 验证参数
*/
String startPasskeyAssertion(String id);
String startPasskeyAssertion(String id) throws JsonProcessingException;
/**
* 验证通行密钥
@ -40,5 +41,5 @@ public interface PasskeyAuthorizationService {
* @param id 登录id
* @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.RegisteredCredential;
import com.yubico.webauthn.RegistrationResult;
import com.yubico.webauthn.data.AuthenticatorTransport;
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.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@ -53,11 +55,12 @@ public class CredentialRepositoryImpl implements CredentialRepository {
.eq(UserCredential::getUserId, one.getId())
.list()
.stream()
.map(UserCredential::getCredential)
// .map(UserCredential::getCredential)
.map(x -> {
RegisteredCredential credential = JSON.parseObject(x, RegisteredCredential.class);
RegisteredCredential credential = JSON.parseObject(x.getCredential(), RegisteredCredential.class);
return PublicKeyCredentialDescriptor.builder()
.id(credential.getCredentialId())
.transports(new HashSet<>(JSON.parseArray(x.getTransports(), AuthenticatorTransport.class)))
.build();
}).collect(Collectors.toSet());
}

View File

@ -1,21 +1,28 @@
package com.kane.animo.auth.service.impl;
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.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.service.PasskeyAuthorizationService;
import com.kane.animo.exception.ServiceException;
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.*;
import com.yubico.webauthn.data.*;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
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
private UserMapper userMapper;
@Resource
private UserCredentialMapper credentialMapper;
@Resource
private CacheService cacheService;
@ -77,6 +87,17 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
.request(request)
.response(pkc)
.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());
}
@ -87,8 +108,10 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
* @return 验证参数
*/
@Override
public String startPasskeyAssertion(String id) {
return "";
public String startPasskeyAssertion(String id) throws JsonProcessingException {
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 凭证
*/
@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) {
registry.addInterceptor(new SaInterceptor(handler -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/auth/login");
// .excludePathPatterns("/auth/register");
.excludePathPatterns("/auth/login")
.excludePathPatterns("/auth/register")
.excludePathPatterns("/passkey/assertion/options")
.excludePathPatterns("/passkey/assertion");
}
}