feat(auth): 支持通行密钥凭证名称和查看功能

- 新增凭证名称字段用于注册时标识不同通行密钥
- 添加获取所有用户凭证接口,返回凭证列表及使用信息
- 更新用户凭证实体类增加名称、最后使用时间和创建时间字段
- 实现凭证信息的 VO 转换与接口暴露
- 增加 ByteArray 的序列化与反序列化支持以适配 WebAuthn 数据结构
- 配置通行密钥依赖方允许来源域名白名单
- 修改异常过滤器中未登录响应状态码为 401
This commit is contained in:
Grand-cocoa 2025-11-11 18:32:20 +08:00
parent 7111ad07d6
commit 3ecff3cec3
10 changed files with 176 additions and 6 deletions

View File

@ -1,6 +1,7 @@
package com.kane.animo.auth.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.kane.animo.auth.domain.vo.UserCredentialVO;
import com.kane.animo.auth.service.PasskeyAuthorizationService;
import com.kane.animo.model.R;
import com.yubico.webauthn.exception.AssertionFailedException;
@ -10,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
/**
* 通行密钥
@ -35,10 +37,11 @@ public class PasskeyAuthorizationController {
/**
* 验证通行密钥
* @param credential 凭证
* @param name 凭证名称
*/
@PostMapping("/registration")
public R<Void> verifyPasskeyRegistration(@RequestBody String credential) throws RegistrationFailedException, IOException {
service.finishPasskeyRegistration(credential);
public R<Void> verifyPasskeyRegistration(@RequestBody String credential, String name) throws RegistrationFailedException, IOException {
service.finishPasskeyRegistration(credential, name);
return R.success();
}
@ -63,4 +66,13 @@ public class PasskeyAuthorizationController {
return R.success(s);
}
/**
* 获取所有凭证
* @return 凭证
*/
@GetMapping("/credentials")
public R<List<UserCredentialVO>> getCredentials() {
return R.success(service.getCredentials());
}
}

View File

@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 用户凭证
* @author Kane
@ -24,6 +26,10 @@ public class UserCredential {
* 用户ID
*/
private Long userId;
/**
* 用户凭证名称
*/
private String name;
/**
* 用户凭证
*/
@ -36,6 +42,14 @@ public class UserCredential {
* 传输方式
*/
private String transports;
/**
* 最后使用时间
*/
private LocalDateTime lastUsed;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 删除标识
*/

View File

@ -0,0 +1,32 @@
package com.kane.animo.auth.domain.vo;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 用户凭证
* @author Kane
* @since 2025/11/11 16:33
*/
@Data
@Accessors(chain = true)
public class UserCredentialVO {
/**
* 凭证ID
*/
private Long id;
/**
* 凭证名称
*/
private String name;
/**
* 最后使用时间
*/
private LocalDateTime lastUsed;
/**
* 创建时间
*/
private LocalDateTime created;
}

View File

@ -1,10 +1,12 @@
package com.kane.animo.auth.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.kane.animo.auth.domain.vo.UserCredentialVO;
import com.yubico.webauthn.exception.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException;
import java.io.IOException;
import java.util.List;
/**
* 通行密钥授权服务
@ -24,8 +26,9 @@ public interface PasskeyAuthorizationService {
* 验证通行密钥
*
* @param credential 凭证
* @param name 凭证名称
*/
void finishPasskeyRegistration(String credential) throws IOException, RegistrationFailedException;
void finishPasskeyRegistration(String credential, String name) throws IOException, RegistrationFailedException;
/**
* 获取通行密钥验证参数
@ -42,4 +45,10 @@ public interface PasskeyAuthorizationService {
* @param credential 凭证
*/
String finishPasskeyAssertion(String id, String credential) throws IOException, AssertionFailedException;
/**
* 获取所有凭证
* @return 凭证
*/
List<UserCredentialVO> getCredentials();
}

View File

@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWra
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.domain.vo.UserCredentialVO;
import com.kane.animo.auth.mapper.UserCredentialMapper;
import com.kane.animo.auth.mapper.UserMapper;
import com.kane.animo.auth.service.PasskeyAuthorizationService;
@ -20,7 +21,7 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashSet;
import java.time.LocalDateTime;
import java.util.List;
import java.util.TreeSet;
@ -73,9 +74,10 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
* 验证通行密钥
*
* @param credential 凭证
* @param name 凭证名称
*/
@Override
public void finishPasskeyRegistration(String credential) throws IOException, RegistrationFailedException {
public void finishPasskeyRegistration(String credential, String name) throws IOException, RegistrationFailedException {
User user = userMapper.selectById(StpUtil.getLoginIdAsLong());
PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkc =
PublicKeyCredential.parseRegistrationResponseJson(credential);
@ -89,6 +91,7 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
.build());
UserCredential userCredential = new UserCredential()
.setUserId(user.getId())
.setName(name)
.setIdentity(JSON.toJSONString(request.getUser()))
.setTransports(JSON.toJSONString(result.getKeyId().getTransports().orElse(new TreeSet<>())))
.setCredential(JSON.toJSONString(RegisteredCredential.builder()
@ -145,6 +148,7 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
RegisteredCredential build = parsed.toBuilder().signatureCount(result.getSignatureCount()).build();
new LambdaUpdateChainWrapper<>(credentialMapper)
.set(UserCredential::getCredential, JSON.toJSONString(build))
.set(UserCredential::getLastUsed, LocalDateTime.now())
.eq(UserCredential::getId, userCredential.getId())
.update();
}
@ -152,4 +156,23 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
StpUtil.login(one.getId());
return result.getUsername();
}
/**
* 获取所有凭证
*
* @return 凭证
*/
@Override
public List<UserCredentialVO> getCredentials() {
List<UserCredential> list = new LambdaQueryChainWrapper<>(credentialMapper)
.eq(UserCredential::getUserId, StpUtil.getLoginIdAsLong())
.list();
return list.stream()
.map(x -> new UserCredentialVO()
.setId(x.getId())
.setName(x.getName())
.setCreated(x.getCreateTime())
.setLastUsed(x.getLastUsed()))
.toList();
}
}

View File

@ -0,0 +1,22 @@
package com.kane.animo.config;
import com.alibaba.fastjson2.JSON;
import com.kane.animo.config.serializer.ByteArrayDeserializer;
import com.kane.animo.config.serializer.ByteArraySerializer;
import com.yubico.webauthn.RegisteredCredential;
import com.yubico.webauthn.data.ByteArray;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;
/**
* @author Kane
* @since 2025/11/11 18:21
*/
@Configuration
public class JsonConfigurer {
@PostConstruct
public void configure() {
JSON.register(ByteArray.class, ByteArraySerializer.INSTANCE);
JSON.register(ByteArray.class, ByteArrayDeserializer.INSTANCE);
}
}

View File

@ -9,6 +9,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Set;
/**
* 通行密钥配置
* @author Kane
@ -31,6 +33,10 @@ public class PasskeyConfigurer {
return RelyingParty.builder()
.identity(rpIdentity)
.credentialRepository(credentialRepository)
.origins(Set.of(
"http://localhost:5173",
"https://animo.alina-dace.info"
))
.build();
}
}

View File

@ -0,0 +1,27 @@
package com.kane.animo.config.serializer;
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.reader.ObjectReader;
import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.exception.Base64UrlException;
import java.lang.reflect.Type;
/**
* @author Kane
* @since 2025/11/11 18:11
*/
public class ByteArrayDeserializer implements ObjectReader<ByteArray> {
public static final ByteArrayDeserializer INSTANCE = new ByteArrayDeserializer();
@Override
public ByteArray readObject(JSONReader jsonReader, Type fieldType, Object fieldName, long features) {
String s = jsonReader.readString();
try {
return ByteArray.fromBase64Url(s);
} catch (Base64UrlException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,25 @@
package com.kane.animo.config.serializer;
import com.alibaba.fastjson2.JSONWriter;
import com.alibaba.fastjson2.writer.ObjectWriter;
import com.yubico.webauthn.data.ByteArray;
import java.lang.reflect.Type;
/**
* @author Kane
* @since 2025/11/11 18:11
*/
public class ByteArraySerializer implements ObjectWriter<ByteArray> {
public static final ByteArraySerializer INSTANCE = new ByteArraySerializer();
@Override
public void write(JSONWriter jsonWriter, Object object, Object fieldName, Type fieldType, long features) {
if (object == null){
jsonWriter.writeNull();
}
ByteArray array = (ByteArray) object;
jsonWriter.writeString(array.getBase64Url());
}
}

View File

@ -20,6 +20,6 @@ public class ExceptionFilter {
@ExceptionHandler(NotLoginException.class)
public R<String> handleException(NotLoginException e) {
return R.error("认证失败 - 未登录");
return R.build(401, "认证失败 - 未登录");
}
}