diff --git a/pom.xml b/pom.xml index ad27c0b..bd2b91f 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,22 @@ sa-token-spring-boot3-starter 1.39.0 + + com.alibaba.fastjson2 + fastjson2 + 2.0.52 + + + com.yubico + webauthn-server-core + 2.7.0 + compile + + + + org.ehcache + ehcache + diff --git a/src/main/java/com/kane/animo/auth/controller/PasskeyAuthorizationController.java b/src/main/java/com/kane/animo/auth/controller/PasskeyAuthorizationController.java new file mode 100644 index 0000000..29589b0 --- /dev/null +++ b/src/main/java/com/kane/animo/auth/controller/PasskeyAuthorizationController.java @@ -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 getPasskeyRegistrationOptions() throws JsonProcessingException { + return R.success(service.startPasskeyRegistration()); + } + + /** + * 验证通行密钥 + * @param credential 凭证 + */ + @PostMapping("/registration") + public R verifyPasskeyRegistration(@RequestBody String credential) throws RegistrationFailedException, IOException { + service.finishPasskeyRegistration(credential); + return R.success(); + } + + /** + * 获取通行密钥验证参数 + * @param httpServletRequest 请求 + * @return 验证参数 + */ + @GetMapping("/assertion/options") + public R getPasskeyAssertionOptions(HttpServletRequest httpServletRequest) { + return R.success(service.startPasskeyAssertion(httpServletRequest.getSession().getId())); + } + + /** + * 验证通行密钥 + * @param httpServletRequest 请求 + * @param credential 凭证 + */ + @PostMapping("/assertion") + public R verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) { + service.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential); + return R.success(); + } + +} diff --git a/src/main/java/com/kane/animo/auth/domain/UserCredential.java b/src/main/java/com/kane/animo/auth/domain/UserCredential.java new file mode 100644 index 0000000..b75d37f --- /dev/null +++ b/src/main/java/com/kane/animo/auth/domain/UserCredential.java @@ -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; +} diff --git a/src/main/java/com/kane/animo/auth/mapper/UserCredentialMapper.java b/src/main/java/com/kane/animo/auth/mapper/UserCredentialMapper.java new file mode 100644 index 0000000..af0dc45 --- /dev/null +++ b/src/main/java/com/kane/animo/auth/mapper/UserCredentialMapper.java @@ -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 { +} diff --git a/src/main/java/com/kane/animo/auth/service/PasskeyAuthorizationService.java b/src/main/java/com/kane/animo/auth/service/PasskeyAuthorizationService.java new file mode 100644 index 0000000..d543e86 --- /dev/null +++ b/src/main/java/com/kane/animo/auth/service/PasskeyAuthorizationService.java @@ -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); +} diff --git a/src/main/java/com/kane/animo/auth/service/impl/CredentialRepositoryImpl.java b/src/main/java/com/kane/animo/auth/service/impl/CredentialRepositoryImpl.java new file mode 100644 index 0000000..a9dda56 --- /dev/null +++ b/src/main/java/com/kane/animo/auth/service/impl/CredentialRepositoryImpl.java @@ -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. + * + *

After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method + * returns a value suitable for inclusion in this set. + * + *

Implementations of this method MUST NOT return null. + * + * @param username + */ + @Override + public Set 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)}. + * + *

Used to look up the user handle based on the username, for authentication ceremonies where + * the username is already given. + * + *

Implementations of this method MUST NOT return null. + * + * @param username + */ + @Override + public Optional 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)}. + * + *

Used to look up the username based on the user handle, for username-less authentication + * ceremonies. + * + *

Implementations of this method MUST NOT return null. + * + * @param userHandle + */ + @Override + public Optional getUsernameForUserHandle(ByteArray userHandle) { + Set 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. + * + *

The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read + * directly from a database or assembled from other components. + * + *

Implementations of this method MUST NOT return null. + * + * @param credentialId + * @param userHandle + */ + @Override + public Optional lookup(ByteArray credentialId, ByteArray userHandle) { + List 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. + * + *

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). + * + *

Implementations of this method MUST NOT return null. + * + * @param credentialId + */ + @Override + public Set lookupAll(ByteArray credentialId) { + List userCredentials = credentialMapper.selectList(null); + return userCredentials.stream() + .map(x -> JSON.parseObject(x.getCredential(), RegisteredCredential.class)) + .filter(x -> x.getCredentialId().equals(credentialId)) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/com/kane/animo/auth/service/impl/PasskeyAuthorizationServiceImpl.java b/src/main/java/com/kane/animo/auth/service/impl/PasskeyAuthorizationServiceImpl.java new file mode 100644 index 0000000..53f631f --- /dev/null +++ b/src/main/java/com/kane/animo/auth/service/impl/PasskeyAuthorizationServiceImpl.java @@ -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 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) { + + } +} diff --git a/src/main/java/com/kane/animo/config/CacheConfigurer.java b/src/main/java/com/kane/animo/config/CacheConfigurer.java new file mode 100644 index 0000000..32968c2 --- /dev/null +++ b/src/main/java/com/kane/animo/config/CacheConfigurer.java @@ -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); + } + +} diff --git a/src/main/java/com/kane/animo/config/PasskeyConfigurer.java b/src/main/java/com/kane/animo/config/PasskeyConfigurer.java new file mode 100644 index 0000000..0409be5 --- /dev/null +++ b/src/main/java/com/kane/animo/config/PasskeyConfigurer.java @@ -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(); + } +} diff --git a/src/main/java/com/kane/animo/config/properties/PasskeyProperties.java b/src/main/java/com/kane/animo/config/properties/PasskeyProperties.java new file mode 100644 index 0000000..3fadfa6 --- /dev/null +++ b/src/main/java/com/kane/animo/config/properties/PasskeyProperties.java @@ -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; +} diff --git a/src/main/java/com/kane/animo/util/CacheService.java b/src/main/java/com/kane/animo/util/CacheService.java new file mode 100644 index 0000000..af8b600 --- /dev/null +++ b/src/main/java/com/kane/animo/util/CacheService.java @@ -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 getBucket(){ + return manager.getCache("cache", String.class, String.class); + } + + public void setCache(String key, Object value){ + getBucket().put(key, JSON.toJSONString(value)); + } + + public T getCache(String key, Class clazz){ + String json = getBucket().get(key); + return JSON.parseObject(json, clazz); + } + + public void removeCache(String key){ + getBucket().remove(key); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..7be8ffd --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,3 @@ +passkey: + id: "localhost" + name: "Animo" diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..87921b8 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,3 @@ +passkey: + id: "animo.alina-dace.info" + name: "Animo" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9eadcb5..351091e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,8 @@ server: port: 8080 spring: + profiles: + active: dev application: name: Animo