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; package com.kane.animo.auth.controller;
import com.fasterxml.jackson.core.JsonProcessingException; 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.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.AssertionFailedException;
@ -10,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException; import java.io.IOException;
import java.util.List;
/** /**
* 通行密钥 * 通行密钥
@ -35,10 +37,11 @@ public class PasskeyAuthorizationController {
/** /**
* 验证通行密钥 * 验证通行密钥
* @param credential 凭证 * @param credential 凭证
* @param name 凭证名称
*/ */
@PostMapping("/registration") @PostMapping("/registration")
public R<Void> verifyPasskeyRegistration(@RequestBody String credential) throws RegistrationFailedException, IOException { public R<Void> verifyPasskeyRegistration(@RequestBody String credential, String name) throws RegistrationFailedException, IOException {
service.finishPasskeyRegistration(credential); service.finishPasskeyRegistration(credential, name);
return R.success(); return R.success();
} }
@ -63,4 +66,13 @@ public class PasskeyAuthorizationController {
return R.success(s); 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.Data;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/** /**
* 用户凭证 * 用户凭证
* @author Kane * @author Kane
@ -24,6 +26,10 @@ public class UserCredential {
* 用户ID * 用户ID
*/ */
private Long userId; private Long userId;
/**
* 用户凭证名称
*/
private String name;
/** /**
* 用户凭证 * 用户凭证
*/ */
@ -36,6 +42,14 @@ public class UserCredential {
* 传输方式 * 传输方式
*/ */
private String transports; 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; package com.kane.animo.auth.service;
import com.fasterxml.jackson.core.JsonProcessingException; 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.AssertionFailedException;
import com.yubico.webauthn.exception.RegistrationFailedException; import com.yubico.webauthn.exception.RegistrationFailedException;
import java.io.IOException; import java.io.IOException;
import java.util.List;
/** /**
* 通行密钥授权服务 * 通行密钥授权服务
@ -24,8 +26,9 @@ public interface PasskeyAuthorizationService {
* 验证通行密钥 * 验证通行密钥
* *
* @param credential 凭证 * @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 凭证 * @param credential 凭证
*/ */
String finishPasskeyAssertion(String id, String credential) throws IOException, AssertionFailedException; 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.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.domain.UserCredential;
import com.kane.animo.auth.domain.vo.UserCredentialVO;
import com.kane.animo.auth.mapper.UserCredentialMapper; 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;
@ -20,7 +21,7 @@ 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.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.TreeSet; import java.util.TreeSet;
@ -73,9 +74,10 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
* 验证通行密钥 * 验证通行密钥
* *
* @param credential 凭证 * @param credential 凭证
* @param name 凭证名称
*/ */
@Override @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()); User user = userMapper.selectById(StpUtil.getLoginIdAsLong());
PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkc = PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkc =
PublicKeyCredential.parseRegistrationResponseJson(credential); PublicKeyCredential.parseRegistrationResponseJson(credential);
@ -89,6 +91,7 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
.build()); .build());
UserCredential userCredential = new UserCredential() UserCredential userCredential = new UserCredential()
.setUserId(user.getId()) .setUserId(user.getId())
.setName(name)
.setIdentity(JSON.toJSONString(request.getUser())) .setIdentity(JSON.toJSONString(request.getUser()))
.setTransports(JSON.toJSONString(result.getKeyId().getTransports().orElse(new TreeSet<>()))) .setTransports(JSON.toJSONString(result.getKeyId().getTransports().orElse(new TreeSet<>())))
.setCredential(JSON.toJSONString(RegisteredCredential.builder() .setCredential(JSON.toJSONString(RegisteredCredential.builder()
@ -145,6 +148,7 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
RegisteredCredential build = parsed.toBuilder().signatureCount(result.getSignatureCount()).build(); RegisteredCredential build = parsed.toBuilder().signatureCount(result.getSignatureCount()).build();
new LambdaUpdateChainWrapper<>(credentialMapper) new LambdaUpdateChainWrapper<>(credentialMapper)
.set(UserCredential::getCredential, JSON.toJSONString(build)) .set(UserCredential::getCredential, JSON.toJSONString(build))
.set(UserCredential::getLastUsed, LocalDateTime.now())
.eq(UserCredential::getId, userCredential.getId()) .eq(UserCredential::getId, userCredential.getId())
.update(); .update();
} }
@ -152,4 +156,23 @@ public class PasskeyAuthorizationServiceImpl implements PasskeyAuthorizationServ
StpUtil.login(one.getId()); StpUtil.login(one.getId());
return result.getUsername(); 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.util.Set;
/** /**
* 通行密钥配置 * 通行密钥配置
* @author Kane * @author Kane
@ -31,6 +33,10 @@ public class PasskeyConfigurer {
return RelyingParty.builder() return RelyingParty.builder()
.identity(rpIdentity) .identity(rpIdentity)
.credentialRepository(credentialRepository) .credentialRepository(credentialRepository)
.origins(Set.of(
"http://localhost:5173",
"https://animo.alina-dace.info"
))
.build(); .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) @ExceptionHandler(NotLoginException.class)
public R<String> handleException(NotLoginException e) { public R<String> handleException(NotLoginException e) {
return R.error("认证失败 - 未登录"); return R.build(401, "认证失败 - 未登录");
} }
} }