diff --git a/build.gradle b/build.gradle index 7eab00cdf..e916407ee 100644 --- a/build.gradle +++ b/build.gradle @@ -419,12 +419,17 @@ subprojects { implementation group: 'org.webjars', name: 'webjars-locator', version: "${webjarslocatorVersion}" implementation group: 'org.webjars', name: 'webjars-locator-lite', version: "${webjarslocatorliteVersion}" implementation group: 'org.webjars', name: 'swagger-ui', version: "${swaggeruiVersion}" + implementation "com.webauthn4j:webauthn4j-core:${webauthn4jVersion}" + implementation "com.webauthn4j:webauthn4j-util:${webauthn4jVersion}" + implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-cbor', version: "${jacksonVersion}" //knife4j //implementation group: 'com.github.xiaoymin', name: 'knife4j-core', version: "${knife4jVersion}" //implementation group: 'com.github.xiaoymin', name: 'knife4j-openapi3-ui', version: "${knife4jVersion}" //implementation group: 'com.github.xiaoymin', name: 'knife4j-openapi3-jakarta-spring-boot-starter', version: "${knife4jVersion}" //local jars + implementation fileTree(dir: "${rootDir}/maxkey-lib/", include: '*.jar') + } jar { diff --git a/gradle.properties b/gradle.properties index 36a810b15..408948f91 100644 --- a/gradle.properties +++ b/gradle.properties @@ -177,6 +177,7 @@ reflectionsVersion =0.10.2 jdom2Version =2.0.6.1 dom4jVersion =2.1.4 serializerVersion =2.7.2 +webauthn4jVersion =0.29.4.RELEASE xmlresolverVersion =1.2 xmlsecVersion =2.1.7 xpp3Version =1.1.6 diff --git a/maxkey-entity/src/main/java/org/dromara/maxkey/passkey/PasskeyChallenge.java b/maxkey-entity/src/main/java/org/dromara/maxkey/passkey/PasskeyChallenge.java new file mode 100644 index 000000000..e7f3b1a1b --- /dev/null +++ b/maxkey-entity/src/main/java/org/dromara/maxkey/passkey/PasskeyChallenge.java @@ -0,0 +1,167 @@ +/* + * Copyright [2025] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.entity.passkey; + +import java.io.Serializable; +import java.util.Date; +import jakarta.persistence.*; +import org.dromara.mybatis.jpa.entity.JpaEntity; + +/** + * Passkey挑战实体类,用于存储注册和认证过程中的挑战信息 + */ +@Entity +@Table(name = "mxk_passkey_challenges") +public class PasskeyChallenge extends JpaEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id", length = 45) + private String id; + + @Column(name = "user_id", length = 45) + private String userId; + + @Column(name = "challenge", length = 500, nullable = false) + private String challenge; + + @Column(name = "challenge_type", length = 20, nullable = false) + private String challengeType; // REGISTRATION 或 AUTHENTICATION + + @Column(name = "session_id", length = 100) + private String sessionId; + + @Column(name = "created_date", nullable = false) + private Date createdDate; + + @Column(name = "expires_date", nullable = false) + private Date expiresDate; + + @Column(name = "status", nullable = false) + private Integer status = 0; // 0: 未使用, 1: 已使用, 2: 已过期 + + @Column(name = "inst_id", length = 45, nullable = false) + private String instId; + + // 构造函数 + public PasskeyChallenge() { + Date now = new Date(); + this.createdDate = now; + this.expiresDate = new Date(now.getTime() + 5 * 60 * 1000); // 5分钟过期 + } + + public PasskeyChallenge(String id, String challenge, String challengeType) { + this.id = id; + this.challenge = challenge; + this.challengeType = challengeType; + Date now = new Date(); + this.createdDate = now; + this.expiresDate = new Date(now.getTime() + 5 * 60 * 1000); + } + + // Getter和Setter方法 + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getChallenge() { + return challenge; + } + + public void setChallenge(String challenge) { + this.challenge = challenge; + } + + public String getChallengeType() { + return challengeType; + } + + public void setChallengeType(String challengeType) { + this.challengeType = challengeType; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public Date getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Date createdDate) { + this.createdDate = createdDate; + } + + public Date getExpiresDate() { + return expiresDate; + } + + public void setExpiresDate(Date expiresDate) { + this.expiresDate = expiresDate; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getInstId() { + return instId; + } + + public void setInstId(String instId) { + this.instId = instId; + } + + // 检查是否过期 + public boolean isExpired() { + return new Date().after(this.expiresDate); + } + + @Override + public String toString() { + return "PasskeyChallenge{" + + "id='" + id + '\'' + + ", userId='" + userId + '\'' + + ", challengeType='" + challengeType + '\'' + + ", sessionId='" + sessionId + '\'' + + ", createdDate=" + createdDate + + ", expiresDate=" + expiresDate + + ", status=" + status + + ", instId='" + instId + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/maxkey-entity/src/main/java/org/dromara/maxkey/passkey/UserPasskey.java b/maxkey-entity/src/main/java/org/dromara/maxkey/passkey/UserPasskey.java new file mode 100644 index 000000000..31b69bb89 --- /dev/null +++ b/maxkey-entity/src/main/java/org/dromara/maxkey/passkey/UserPasskey.java @@ -0,0 +1,193 @@ +/* + * Copyright [2025] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.entity.passkey; + +import java.io.Serializable; +import java.util.Date; +import jakarta.persistence.*; +import org.dromara.mybatis.jpa.entity.JpaEntity; + +/** + * 用户Passkey凭据实体类 + */ +@Entity +@Table(name = "mxk_user_passkeys") +public class UserPasskey extends JpaEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id", length = 45) + private String id; + + @Column(name = "user_id", length = 45, nullable = false) + private String userId; + + @Column(name = "credential_id", length = 500, nullable = false) + private String credentialId; + + @Column(name = "public_key", columnDefinition = "TEXT", nullable = false) + private String publicKey; + + @Column(name = "signature_count", nullable = false) + private Long signatureCount = 0L; + + @Column(name = "aaguid", length = 100) + private String aaguid; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(name = "device_type", length = 50) + private String deviceType; + + @Column(name = "created_date", nullable = false) + private Date createdDate; + + @Column(name = "last_used_date") + private Date lastUsedDate; + + @Column(name = "status", nullable = false) + private Integer status = 1; // 1: 活跃, 0: 禁用 + + @Column(name = "inst_id", length = 45, nullable = false) + private String instId; + + // 构造函数 + public UserPasskey() { + this.createdDate = new Date(); + } + + public UserPasskey(String id, String userId, String credentialId, String publicKey) { + this.id = id; + this.userId = userId; + this.credentialId = credentialId; + this.publicKey = publicKey; + this.createdDate = new Date(); + } + + // Getter和Setter方法 + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getCredentialId() { + return credentialId; + } + + public void setCredentialId(String credentialId) { + this.credentialId = credentialId; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + public Long getSignatureCount() { + return signatureCount; + } + + public void setSignatureCount(Long signatureCount) { + this.signatureCount = signatureCount; + } + + public String getAaguid() { + return aaguid; + } + + public void setAaguid(String aaguid) { + this.aaguid = aaguid; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDeviceType() { + return deviceType; + } + + public void setDeviceType(String deviceType) { + this.deviceType = deviceType; + } + + public Date getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Date createdDate) { + this.createdDate = createdDate; + } + + public Date getLastUsedDate() { + return lastUsedDate; + } + + public void setLastUsedDate(Date lastUsedDate) { + this.lastUsedDate = lastUsedDate; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getInstId() { + return instId; + } + + public void setInstId(String instId) { + this.instId = instId; + } + + @Override + public String toString() { + return "UserPasskey{" + + "id='" + id + '\'' + + ", userId='" + userId + '\'' + + ", credentialId='" + credentialId + '\'' + + ", displayName='" + displayName + '\'' + + ", deviceType='" + deviceType + '\'' + + ", createdDate=" + createdDate + + ", lastUsedDate=" + lastUsedDate + + ", status=" + status + + ", instId='" + instId + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/mapper/PasskeyChallengeMapper.java b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/mapper/PasskeyChallengeMapper.java new file mode 100644 index 000000000..0d6c928cd --- /dev/null +++ b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/mapper/PasskeyChallengeMapper.java @@ -0,0 +1,128 @@ +/* + * Copyright [2025] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.persistence.mapper; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Result; +import org.apache.ibatis.annotations.Results; +import org.apache.ibatis.annotations.Select; +import org.dromara.maxkey.entity.passkey.PasskeyChallenge; +import org.dromara.mybatis.jpa.IJpaMapper; + +/** + * PasskeyChallenge Mapper 接口 + * 提供 Passkey 挑战数据的数据库操作方法 + * + * @author MaxKey Team + */ +public interface PasskeyChallengeMapper extends IJpaMapper { + + /** + * 根据挑战ID查询挑战数据 + * + * @param challengeId 挑战ID + * @return PasskeyChallenge对象 + */ + @Select("SELECT * FROM mxk_passkey_challenges WHERE id = #{challengeId}") + @Results({ + @Result(column = "id", property = "id"), + @Result(column = "user_id", property = "userId"), + @Result(column = "challenge", property = "challenge"), + @Result(column = "challenge_type", property = "challengeType"), + @Result(column = "session_id", property = "sessionId"), + @Result(column = "created_date", property = "createdDate"), + @Result(column = "expires_date", property = "expiresDate"), + @Result(column = "status", property = "status"), + @Result(column = "inst_id", property = "instId") + }) + PasskeyChallenge findByChallengeId(String challengeId); + + /** + * 根据用户ID和挑战类型查询最新的挑战 + * + * @param userId 用户ID + * @param challengeType 挑战类型(REGISTRATION/AUTHENTICATION) + * @return PasskeyChallenge对象 + */ + @Select("SELECT * FROM mxk_passkey_challenges WHERE user_id = #{userId} AND challenge_type = #{challengeType} ORDER BY created_date DESC LIMIT 1") + @Results({ + @Result(column = "id", property = "id"), + @Result(column = "user_id", property = "userId"), + @Result(column = "challenge", property = "challenge"), + @Result(column = "challenge_type", property = "challengeType"), + @Result(column = "session_id", property = "sessionId"), + @Result(column = "created_date", property = "createdDate"), + @Result(column = "expires_date", property = "expiresDate"), + @Result(column = "status", property = "status"), + @Result(column = "inst_id", property = "instId") + }) + PasskeyChallenge findLatestByUserIdAndType(String userId, String challengeType); + + /** + * 删除指定挑战ID的记录 + * + * @param challengeId 挑战ID + * @return 删除的记录数 + */ + @Delete("DELETE FROM mxk_passkey_challenges WHERE id = #{challengeId}") + int deleteByChallengeId(String challengeId); + + /** + * 清理过期的挑战记录 + * + * @return 清理的记录数 + */ + @Delete("DELETE FROM mxk_passkey_challenges WHERE expires_date < NOW()") + int cleanExpiredChallenges(); + + /** + * 清理指定用户的所有挑战记录 + * + * @param userId 用户ID + * @return 清理的记录数 + */ + @Delete("DELETE FROM mxk_passkey_challenges WHERE user_id = #{userId}") + int deleteByUserId(String userId); + + /** + * 清理指定用户和类型的挑战记录 + * + * @param userId 用户ID + * @param challengeType 挑战类型 + * @return 清理的记录数 + */ + @Delete("DELETE FROM mxk_passkey_challenges WHERE user_id = #{userId} AND challenge_type = #{challengeType}") + int deleteByUserIdAndType(String userId, String challengeType); + + /** + * 统计指定用户的挑战数量 + * + * @param userId 用户ID + * @return 挑战数量 + */ + @Select("SELECT COUNT(*) FROM mxk_passkey_challenges WHERE user_id = #{userId}") + int countByUserId(String userId); + + /** + * 检查挑战ID是否存在且未过期 + * + * @param challengeId 挑战ID + * @return 是否存在且有效 + */ + @Select("SELECT COUNT(*) > 0 FROM mxk_passkey_challenges WHERE id = #{challengeId} AND expires_date > NOW()") + boolean existsValidChallenge(String challengeId); +} \ No newline at end of file diff --git a/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/mapper/UserPasskeyMapper.java b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/mapper/UserPasskeyMapper.java new file mode 100644 index 000000000..75779ceff --- /dev/null +++ b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/mapper/UserPasskeyMapper.java @@ -0,0 +1,152 @@ +/* + * Copyright [2025] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.persistence.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Result; +import org.apache.ibatis.annotations.Results; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.mybatis.jpa.IJpaMapper; + +/** + * UserPasskey Mapper 接口 + * 提供用户 Passkey 凭据的数据库操作方法 + * + * @author MaxKey Team + */ +public interface UserPasskeyMapper extends IJpaMapper { + + /** + * 根据用户ID查询所有Passkey凭据 + * + * @param userId 用户ID + * @return Passkey凭据列表 + */ + @Select("SELECT * FROM mxk_user_passkeys WHERE user_id = #{userId} AND status = 1 ORDER BY created_date DESC") + @Results({ + @Result(column = "id", property = "id"), + @Result(column = "user_id", property = "userId"), + @Result(column = "credential_id", property = "credentialId"), + @Result(column = "public_key", property = "publicKey"), + @Result(column = "signature_count", property = "signatureCount"), + @Result(column = "aaguid", property = "aaguid"), + @Result(column = "display_name", property = "displayName"), + @Result(column = "device_type", property = "deviceType"), + @Result(column = "created_date", property = "createdDate"), + @Result(column = "last_used_date", property = "lastUsedDate"), + @Result(column = "status", property = "status"), + @Result(column = "inst_id", property = "instId") + }) + List findByUserId(String userId); + + /** + * 根据凭据ID查询Passkey + * + * @param credentialId 凭据ID(Base64编码) + * @return UserPasskey对象 + */ + @Select("SELECT * FROM mxk_user_passkeys WHERE credential_id = #{credentialId} AND status = 1") + @Results({ + @Result(column = "id", property = "id"), + @Result(column = "user_id", property = "userId"), + @Result(column = "credential_id", property = "credentialId"), + @Result(column = "public_key", property = "publicKey"), + @Result(column = "signature_count", property = "signatureCount"), + @Result(column = "aaguid", property = "aaguid"), + @Result(column = "display_name", property = "displayName"), + @Result(column = "device_type", property = "deviceType"), + @Result(column = "created_date", property = "createdDate"), + @Result(column = "last_used_date", property = "lastUsedDate"), + @Result(column = "status", property = "status"), + @Result(column = "inst_id", property = "instId") + }) + UserPasskey findByCredentialId(String credentialId); + + /** + * 根据用户ID和凭据ID查询Passkey + * + * @param userId 用户ID + * @param credentialId 凭据ID + * @return UserPasskey对象 + */ + @Select("SELECT * FROM mxk_user_passkeys WHERE user_id = #{userId} AND credential_id = #{credentialId} AND status = 1") + @Results({ + @Result(column = "id", property = "id"), + @Result(column = "user_id", property = "userId"), + @Result(column = "credential_id", property = "credentialId"), + @Result(column = "public_key", property = "publicKey"), + @Result(column = "signature_count", property = "signatureCount"), + @Result(column = "aaguid", property = "aaguid"), + @Result(column = "display_name", property = "displayName"), + @Result(column = "device_type", property = "deviceType"), + @Result(column = "created_date", property = "createdDate"), + @Result(column = "last_used_date", property = "lastUsedDate"), + @Result(column = "status", property = "status"), + @Result(column = "inst_id", property = "instId") + }) + UserPasskey findByUserIdAndCredentialId(String userId, String credentialId); + + /** + * 更新签名计数器 + * + * @param credentialId 凭据ID + * @param signatureCount 新的签名计数 + * @return 更新的记录数 + */ + @Update("UPDATE mxk_user_passkeys SET signature_count = #{signatureCount}, last_used_date = NOW() WHERE credential_id = #{credentialId}") + int updateSignatureCount(String credentialId, Long signatureCount); + + /** + * 物理删除Passkey + * + * @param userId 用户ID + * @param credentialId 凭据ID + * @return 删除的记录数 + */ + @Delete("DELETE FROM mxk_user_passkeys WHERE user_id = #{userId} AND credential_id = #{credentialId}") + int deleteByUserIdAndCredentialId(String userId, String credentialId); + + /** + * 物理删除过期的Passkey记录 + * + * @return 删除的记录数 + */ + @Delete("DELETE FROM mxk_user_passkeys WHERE status = 0 AND created_date < DATE_SUB(NOW(), INTERVAL 30 DAY)") + int cleanExpiredPasskeys(); + + /** + * 统计用户的Passkey数量 + * + * @param userId 用户ID + * @return Passkey数量 + */ + @Select("SELECT COUNT(*) FROM mxk_user_passkeys WHERE user_id = #{userId} AND status = 1") + int countByUserId(String userId); + + /** + * 检查凭据ID是否已存在 + * + * @param credentialId 凭据ID + * @return 是否存在 + */ + @Select("SELECT COUNT(*) > 0 FROM mxk_user_passkeys WHERE credential_id = #{credentialId} AND status = 1") + boolean existsByCredentialId(String credentialId); +} \ No newline at end of file diff --git a/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/PasskeyChallengeService.java b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/PasskeyChallengeService.java new file mode 100644 index 000000000..e78a9472e --- /dev/null +++ b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/PasskeyChallengeService.java @@ -0,0 +1,111 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.persistence.service; + +import org.dromara.maxkey.entity.passkey.PasskeyChallenge; +import org.dromara.mybatis.jpa.IJpaService; + +/** + * PasskeyChallenge Service 接口 + * 提供 Passkey 挑战数据的业务操作方法 + * + * @author MaxKey Team + */ +public interface PasskeyChallengeService extends IJpaService { + + /** + * 根据挑战ID查询挑战数据 + * + * @param challengeId 挑战ID + * @return PasskeyChallenge对象 + */ + PasskeyChallenge findByChallengeId(String challengeId); + + /** + * 根据用户ID和挑战类型查询最新的挑战 + * + * @param userId 用户ID + * @param challengeType 挑战类型(REGISTRATION/AUTHENTICATION) + * @return PasskeyChallenge对象 + */ + PasskeyChallenge findLatestByUserIdAndType(String userId, String challengeType); + + /** + * 保存挑战数据 + * + * @param challenge 挑战对象 + * @return 是否成功 + */ + boolean saveChallenge(PasskeyChallenge challenge); + + /** + * 删除指定挑战ID的记录 + * + * @param challengeId 挑战ID + * @return 是否成功 + */ + boolean deleteByChallengeId(String challengeId); + + /** + * 清理过期的挑战记录 + * + * @return 清理的记录数 + */ + int cleanExpiredChallenges(); + + /** + * 清理指定用户的所有挑战记录 + * + * @param userId 用户ID + * @return 清理的记录数 + */ + int deleteByUserId(String userId); + + /** + * 清理指定用户和类型的挑战记录 + * + * @param userId 用户ID + * @param challengeType 挑战类型 + * @return 清理的记录数 + */ + int deleteByUserIdAndType(String userId, String challengeType); + + /** + * 统计指定用户的挑战数量 + * + * @param userId 用户ID + * @return 挑战数量 + */ + int countByUserId(String userId); + + /** + * 检查挑战ID是否存在且未过期 + * + * @param challengeId 挑战ID + * @return 是否存在且有效 + */ + boolean existsValidChallenge(String challengeId); + + /** + * 验证并消费挑战(验证后删除) + * + * @param challengeId 挑战ID + * @param expectedType 期望的挑战类型 + * @return 挑战对象,如果无效则返回null + */ + PasskeyChallenge validateAndConsumeChallenge(String challengeId, String expectedType); +} \ No newline at end of file diff --git a/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/UserPasskeyService.java b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/UserPasskeyService.java new file mode 100644 index 000000000..6bea1cab7 --- /dev/null +++ b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/UserPasskeyService.java @@ -0,0 +1,113 @@ +/* + * Copyright [2025] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.persistence.service; + +import java.util.List; + +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.mybatis.jpa.IJpaService; + +/** + * UserPasskey Service 接口 + * 提供用户 Passkey 凭据的业务操作方法 + * + * @author MaxKey Team + */ +public interface UserPasskeyService extends IJpaService { + + /** + * 根据用户ID查询所有Passkey凭据 + * + * @param userId 用户ID + * @return Passkey凭据列表 + */ + List findByUserId(String userId); + + /** + * 根据凭据ID查询Passkey + * + * @param credentialId 凭据ID(Base64编码) + * @return UserPasskey对象 + */ + UserPasskey findByCredentialId(String credentialId); + + /** + * 根据用户ID和凭据ID查询Passkey + * + * @param userId 用户ID + * @param credentialId 凭据ID + * @return UserPasskey对象 + */ + UserPasskey findByUserIdAndCredentialId(String userId, String credentialId); + + /** + * 保存或更新Passkey凭据 + * + * @param userPasskey Passkey凭据对象 + * @return 是否成功 + */ + boolean saveOrUpdatePasskey(UserPasskey userPasskey); + + /** + * 更新签名计数器 + * + * @param credentialId 凭据ID + * @param signatureCount 新的签名计数 + * @return 是否成功 + */ + boolean updateSignatureCount(String credentialId, Long signatureCount); + + /** + * 删除用户的Passkey凭据 + * + * @param userId 用户ID + * @param credentialId 凭据ID + * @return 是否成功 + */ + boolean deletePasskey(String userId, String credentialId); + + /** + * 清理过期的Passkey记录 + * + * @return 清理的记录数 + */ + int cleanExpiredPasskeys(); + + /** + * 统计用户的Passkey数量 + * + * @param userId 用户ID + * @return Passkey数量 + */ + int countByUserId(String userId); + + /** + * 检查凭据ID是否已存在 + * + * @param credentialId 凭据ID + * @return 是否存在 + */ + boolean existsByCredentialId(String credentialId); + + /** + * 获取用户的Passkey统计信息 + * + * @param userId 用户ID + * @return 统计信息Map + */ + java.util.Map getPasskeyStats(String userId); +} \ No newline at end of file diff --git a/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/PasskeyChallengeServiceImpl.java b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/PasskeyChallengeServiceImpl.java new file mode 100644 index 000000000..e92d7ba88 --- /dev/null +++ b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/PasskeyChallengeServiceImpl.java @@ -0,0 +1,191 @@ +/* + * Copyright [2025] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.persistence.service.impl; + +import java.util.Date; + +import org.dromara.maxkey.entity.passkey.PasskeyChallenge; +import org.dromara.maxkey.persistence.mapper.PasskeyChallengeMapper; +import org.dromara.maxkey.persistence.service.PasskeyChallengeService; +import org.dromara.mybatis.jpa.service.impl.JpaServiceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * PasskeyChallenge Service 实现类 + * 提供 Passkey 挑战数据的数据库操作实现 + * + * @author MaxKey Team + */ +@Repository +@Transactional +public class PasskeyChallengeServiceImpl extends JpaServiceImpl implements PasskeyChallengeService { + + private static final Logger _logger = LoggerFactory.getLogger(PasskeyChallengeServiceImpl.class); + + @Override + public PasskeyChallenge findByChallengeId(String challengeId) { + _logger.debug("Finding challenge by ID: {}", challengeId); + try { + return getMapper().findByChallengeId(challengeId); + } catch (Exception e) { + _logger.error("Error finding challenge by ID: {}", challengeId, e); + throw new RuntimeException("Failed to find challenge by ID: " + challengeId, e); + } + } + + @Override + public PasskeyChallenge findLatestByUserIdAndType(String userId, String challengeType) { + _logger.debug("Finding latest challenge for user: {} and type: {}", userId, challengeType); + try { + return getMapper().findLatestByUserIdAndType(userId, challengeType); + } catch (Exception e) { + _logger.error("Error finding latest challenge for user: {} and type: {}", userId, challengeType, e); + throw new RuntimeException("Failed to find latest challenge for user and type", e); + } + } + + @Override + public boolean saveChallenge(PasskeyChallenge challenge) { + _logger.debug("Saving challenge for user: {} and type: {}", challenge.getUserId(), challenge.getChallengeType()); + try { + // 移除重复设置创建时间的代码,因为构造函数中已经正确设置了创建时间和过期时间 + // challenge.setCreatedDate(new Date()); // 删除这行避免时间冲突 + + // 清理该用户同类型的旧挑战(保持数据库整洁) + deleteByUserIdAndType(challenge.getUserId(), challenge.getChallengeType()); + + // 插入新挑战 + return insert(challenge); + } catch (Exception e) { + _logger.error("Error saving challenge for user: {}", challenge.getUserId(), e); + throw new RuntimeException("Failed to save challenge", e); + } + } + + @Override + public boolean deleteByChallengeId(String challengeId) { + _logger.debug("Deleting challenge by ID: {}", challengeId); + try { + int result = getMapper().deleteByChallengeId(challengeId); + return result > 0; + } catch (Exception e) { + _logger.error("Error deleting challenge by ID: {}", challengeId, e); + throw new RuntimeException("Failed to delete challenge by ID: " + challengeId, e); + } + } + + @Override + public int cleanExpiredChallenges() { + _logger.debug("Cleaning expired challenges"); + try { + int result = getMapper().cleanExpiredChallenges(); + _logger.info("Cleaned {} expired challenges", result); + return result; + } catch (Exception e) { + _logger.error("Error cleaning expired challenges", e); + throw new RuntimeException("Failed to clean expired challenges", e); + } + } + + @Override + public int deleteByUserId(String userId) { + _logger.debug("Deleting all challenges for user: {}", userId); + try { + int result = getMapper().deleteByUserId(userId); + _logger.debug("Deleted {} challenges for user: {}", result, userId); + return result; + } catch (Exception e) { + _logger.error("Error deleting challenges for user: {}", userId, e); + throw new RuntimeException("Failed to delete challenges for user: " + userId, e); + } + } + + @Override + public int deleteByUserIdAndType(String userId, String challengeType) { + _logger.debug("Deleting challenges for user: {} and type: {}", userId, challengeType); + try { + int result = getMapper().deleteByUserIdAndType(userId, challengeType); + _logger.debug("Deleted {} challenges for user: {} and type: {}", result, userId, challengeType); + return result; + } catch (Exception e) { + _logger.error("Error deleting challenges for user: {} and type: {}", userId, challengeType, e); + throw new RuntimeException("Failed to delete challenges for user and type", e); + } + } + + @Override + public int countByUserId(String userId) { + _logger.debug("Counting challenges for user: {}", userId); + try { + return getMapper().countByUserId(userId); + } catch (Exception e) { + _logger.error("Error counting challenges for user: {}", userId, e); + throw new RuntimeException("Failed to count challenges for user: " + userId, e); + } + } + + @Override + public boolean existsValidChallenge(String challengeId) { + _logger.debug("Checking if valid challenge exists: {}", challengeId); + try { + return getMapper().existsValidChallenge(challengeId); + } catch (Exception e) { + _logger.error("Error checking valid challenge existence: {}", challengeId, e); + throw new RuntimeException("Failed to check valid challenge existence", e); + } + } + + @Override + public PasskeyChallenge validateAndConsumeChallenge(String challengeId, String expectedType) { + _logger.debug("Validating and consuming challenge: {} with expected type: {}", challengeId, expectedType); + try { + // 查找挑战 + PasskeyChallenge challenge = findByChallengeId(challengeId); + + if (challenge == null) { + _logger.warn("Challenge not found: {}", challengeId); + return null; + } + + // 检查是否过期 + if (challenge.getExpiresDate().before(new Date())) { + _logger.warn("Challenge expired: {}", challengeId); + // 删除过期的挑战 + deleteByChallengeId(challengeId); + return null; + } + + // 检查类型是否匹配 + if (!expectedType.equals(challenge.getChallengeType())) { + _logger.warn("Challenge type mismatch. Expected: {}, Actual: {}", expectedType, challenge.getChallengeType()); + return null; + } + + // 验证成功,删除挑战(消费) + deleteByChallengeId(challengeId); + + _logger.debug("Challenge validated and consumed successfully: {}", challengeId); + return challenge; + } catch (Exception e) { + _logger.error("Error validating and consuming challenge: {}", challengeId, e); + throw new RuntimeException("Failed to validate and consume challenge", e); + } + } +} \ No newline at end of file diff --git a/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/UserPasskeyServiceImpl.java b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/UserPasskeyServiceImpl.java new file mode 100644 index 000000000..8205c406b --- /dev/null +++ b/maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/UserPasskeyServiceImpl.java @@ -0,0 +1,190 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.persistence.service.impl; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.maxkey.persistence.mapper.UserPasskeyMapper; +import org.dromara.maxkey.persistence.service.UserPasskeyService; +import org.dromara.mybatis.jpa.service.impl.JpaServiceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * UserPasskey Service 实现类 + * 提供用户 Passkey 凭据的数据库操作实现 + * + * @author MaxKey Team + */ +@Repository +@Transactional +public class UserPasskeyServiceImpl extends JpaServiceImpl implements UserPasskeyService { + + private static final Logger _logger = LoggerFactory.getLogger(UserPasskeyServiceImpl.class); + + @Override + public List findByUserId(String userId) { + _logger.debug("Finding passkeys for user: {}", userId); + try { + return getMapper().findByUserId(userId); + } catch (Exception e) { + _logger.error("Error finding passkeys for user: {}", userId, e); + throw new RuntimeException("Failed to find passkeys for user: " + userId, e); + } + } + + @Override + public UserPasskey findByCredentialId(String credentialId) { + _logger.debug("Finding passkey by credential ID: {}", credentialId); + try { + return getMapper().findByCredentialId(credentialId); + } catch (Exception e) { + _logger.error("Error finding passkey by credential ID: {}", credentialId, e); + throw new RuntimeException("Failed to find passkey by credential ID: " + credentialId, e); + } + } + + @Override + public UserPasskey findByUserIdAndCredentialId(String userId, String credentialId) { + _logger.debug("Finding passkey for user: {} and credential ID: {}", userId, credentialId); + try { + return getMapper().findByUserIdAndCredentialId(userId, credentialId); + } catch (Exception e) { + _logger.error("Error finding passkey for user: {} and credential ID: {}", userId, credentialId, e); + throw new RuntimeException("Failed to find passkey for user and credential ID", e); + } + } + + @Override + public boolean saveOrUpdatePasskey(UserPasskey userPasskey) { + _logger.debug("Saving or updating passkey for user: {}", userPasskey.getUserId()); + try { + UserPasskey existing = findByCredentialId(userPasskey.getCredentialId()); + + if (existing != null) { + userPasskey.setId(existing.getId()); + userPasskey.setCreatedDate(existing.getCreatedDate()); + userPasskey.setLastUsedDate(new Date()); // 改为 Date + return update(userPasskey); + } else { + userPasskey.setCreatedDate(new Date()); // 改为 Date + return insert(userPasskey); + } + } catch (Exception e) { + _logger.error("Error saving or updating passkey for user: {}", userPasskey.getUserId(), e); + throw new RuntimeException("Failed to save or update passkey", e); + } + } + + @Override + public boolean updateSignatureCount(String credentialId, Long signatureCount) { + _logger.debug("Updating signature count for credential ID: {} to {}", credentialId, signatureCount); + try { + int result = getMapper().updateSignatureCount(credentialId, signatureCount); + return result > 0; + } catch (Exception e) { + _logger.error("Error updating signature count for credential ID: {}", credentialId, e); + throw new RuntimeException("Failed to update signature count", e); + } + } + + @Override + public boolean deletePasskey(String userId, String credentialId) { + _logger.debug("Deleting passkey for user: {} and credential ID: {}", userId, credentialId); + try { + int result = getMapper().deleteByUserIdAndCredentialId(userId, credentialId); + return result > 0; + } catch (Exception e) { + _logger.error("Error deleting passkey for user: {} and credential ID: {}", userId, credentialId, e); + throw new RuntimeException("Failed to delete passkey", e); + } + } + + @Override + public int cleanExpiredPasskeys() { + _logger.debug("Cleaning expired passkeys"); + try { + int result = getMapper().cleanExpiredPasskeys(); + _logger.info("Cleaned {} expired passkeys", result); + return result; + } catch (Exception e) { + _logger.error("Error cleaning expired passkeys", e); + throw new RuntimeException("Failed to clean expired passkeys", e); + } + } + + @Override + public int countByUserId(String userId) { + _logger.debug("Counting passkeys for user: {}", userId); + try { + return getMapper().countByUserId(userId); + } catch (Exception e) { + _logger.error("Error counting passkeys for user: {}", userId, e); + throw new RuntimeException("Failed to count passkeys for user: " + userId, e); + } + } + + @Override + public boolean existsByCredentialId(String credentialId) { + _logger.debug("Checking if credential ID exists: {}", credentialId); + try { + return getMapper().existsByCredentialId(credentialId); + } catch (Exception e) { + _logger.error("Error checking credential ID existence: {}", credentialId, e); + throw new RuntimeException("Failed to check credential ID existence", e); + } + } + + @Override + public Map getPasskeyStats(String userId) { + _logger.debug("Getting passkey stats for user: {}", userId); + try { + Map stats = new HashMap<>(); + + // 获取用户的Passkey数量 + int count = countByUserId(userId); + stats.put("count", count); + + // 获取用户的Passkey列表(用于显示设备信息) + List passkeys = findByUserId(userId); + stats.put("passkeys", passkeys); + + // 计算最后使用时间 + Date lastUsed = null; + for (UserPasskey passkey : passkeys) { + if (passkey.getLastUsedDate() != null) { + if (lastUsed == null || passkey.getLastUsedDate().after(lastUsed)) { + lastUsed = passkey.getLastUsedDate(); + } + } + } + stats.put("lastUsed", lastUsed); + + return stats; + } catch (Exception e) { + _logger.error("Error getting passkey stats for user: {}", userId, e); + throw new RuntimeException("Failed to get passkey stats for user: " + userId, e); + } + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/REFACTORING_SUMMARY.md b/maxkey-starter/maxkey-starter-passkey/REFACTORING_SUMMARY.md new file mode 100644 index 000000000..d00919089 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/REFACTORING_SUMMARY.md @@ -0,0 +1,106 @@ +# PasskeyServiceImpl 重构总结 + +## 重构目标 + +本次重构旨在提高 `PasskeyServiceImpl` 类的代码可读性、可维护性和可测试性,通过方法拆分和工具类提取来优化代码结构。 + +## 主要改进 + +### 1. 方法拆分 + +#### 原有的大方法被拆分为更小、职责单一的方法: + +**`generateRegistrationOptions` 方法拆分:** +- `generateAndSaveChallenge()` - 生成并保存挑战 +- `buildRegistrationOptions()` - 构建注册选项 +- `buildRelyingPartyInfo()` - 构建RP信息 +- `buildUserInfo()` - 构建用户信息 +- `buildPublicKeyCredentialParams()` - 构建公钥凭据参数 +- `buildAuthenticatorSelection()` - 构建认证器选择标准 +- `buildExcludeCredentials()` - 构建排除凭据列表 + +**`verifyRegistrationResponse` 方法拆分:** +- `validateChallenge()` - 验证挑战 +- `parseRegistrationResponse()` - 解析注册响应 +- `createServerProperty()` - 创建服务器属性 +- `performRegistrationVerification()` - 执行注册验证 +- `createUserPasskey()` - 创建UserPasskey对象 + +**`verifyAuthenticationResponse` 方法拆分:** +- `validateChallenge()` - 验证挑战(复用) +- `parseAuthenticationResponse()` - 解析认证响应 +- `validateChallengeUserMatch()` - 验证挑战与用户匹配 +- `performAuthenticationVerification()` - 执行认证验证 +- `buildCredentialRecord()` - 构建凭据记录 + +### 2. 工具类提取 + +创建了 `PasskeyUtils` 工具类,提取了通用的验证和构建逻辑: + +- `generateChallenge()` - 生成挑战 +- `buildRelyingPartyInfo()` - 构建RP信息 +- `buildUserInfo()` - 构建用户信息 +- `buildPublicKeyCredentialParams()` - 构建公钥凭据参数 +- `buildAuthenticatorSelection()` - 构建认证器选择 +- `buildCredentialList()` - 构建凭据列表 +- `createServerProperty()` - 创建服务器属性 +- `parseAndValidateOrigin()` - 解析和验证origin +- `base64Decode()` / `base64Encode()` - Base64编解码 +- `validateNotEmpty()` / `validateNotNull()` - 参数验证 + +### 3. 常量定义 + +添加了常量定义以提高代码可读性: + +```java +private static final String CHALLENGE_TYPE_REGISTRATION = "registration"; +private static final String CHALLENGE_TYPE_AUTHENTICATION = "authentication"; +private static final String CREDENTIAL_TYPE_PUBLIC_KEY = "public-key"; +private static final String DEFAULT_INST_ID = "1"; +private static final String DEFAULT_DEVICE_NAME = "Unknown Device"; +``` + +### 4. 内部类优化 + +添加了 `AuthenticationResponseData` 内部类来封装认证响应数据,提高代码的组织性。 + +### 5. 日志优化 + +- 简化了冗长的调试日志 +- 将详细的调试信息改为debug级别 +- 保留了关键的错误和警告日志 + +## 重构效果 + +### 代码质量提升: +1. **可读性**:方法职责更加单一,代码逻辑更清晰 +2. **可维护性**:功能模块化,便于修改和扩展 +3. **可测试性**:小方法更容易进行单元测试 +4. **复用性**:工具类方法可在其他地方复用 + +### 代码行数优化: +- 原文件:约900行 +- 重构后主文件:约774行 +- 新增工具类:约238行 +- 总体代码更加模块化和组织化 + +## 文件结构 + +``` +passkey/ +├── service/impl/ +│ └── PasskeyServiceImpl.java (重构后的主实现类) +└── util/ + └── PasskeyUtils.java (新增的工具类) +``` + +## 向后兼容性 + +本次重构保持了所有公共接口的兼容性,不会影响现有的调用代码。所有的重构都是内部实现的优化,对外部调用者透明。 + +## 后续建议 + +1. 为新的私有方法添加单元测试 +2. 考虑将一些配置参数提取到配置文件中 +3. 可以进一步优化异常处理机制 +4. 考虑添加更多的参数验证逻辑 \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/build.gradle b/maxkey-starter/maxkey-starter-passkey/build.gradle new file mode 100644 index 000000000..6b38226a4 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/build.gradle @@ -0,0 +1,34 @@ +description = "maxkey-starter-passkey" + +dependencies { + //local jars + implementation fileTree(dir: '../maxkey-lib/', include: '*/*.jar') + + implementation project(":maxkey-commons:maxkey-common") + implementation project(":maxkey-commons:maxkey-crypto") + implementation project(":maxkey-commons:maxkey-ldap") + implementation project(":maxkey-commons:maxkey-core") + implementation project(":maxkey-entity") + implementation project(":maxkey-persistence") + implementation project(":maxkey-authentications:maxkey-authentication-core") + implementation project(":maxkey-authentications:maxkey-authentication-provider") + + // WebAuthn library for Passkey support + implementation "com.webauthn4j:webauthn4j-core:${webauthn4jVersion}" + implementation "com.webauthn4j:webauthn4j-util:${webauthn4jVersion}" + + // WebAuthn4J 的必需传递依赖 + implementation "com.fasterxml.jackson.core:jackson-core:${jacksonVersion}" + implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}" + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:${jacksonVersion}" + implementation "commons-codec:commons-codec:${commonscodecVersion}" + implementation "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}" + implementation "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}" + implementation "org.slf4j:slf4j-api:${slf4jVersion}" + + // 其他可能需要的依赖 + implementation "org.apache.commons:commons-lang3:${commonslang3Version}" +} + +configurations.all { transitive = false } \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/autoconfigure/PasskeyAutoConfiguration.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/autoconfigure/PasskeyAutoConfiguration.java new file mode 100644 index 000000000..01896f58d --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/autoconfigure/PasskeyAutoConfiguration.java @@ -0,0 +1,64 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.autoconfigure; + +import org.dromara.maxkey.passkey.service.PasskeyService; +import org.dromara.maxkey.passkey.service.impl.PasskeyServiceImpl; +import org.dromara.maxkey.passkey.manager.PasskeyManager; +import org.dromara.maxkey.passkey.config.PasskeyProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Passkey自动配置类 + */ +@Configuration +@EnableConfigurationProperties(PasskeyProperties.class) +@EnableScheduling +@ComponentScan(basePackages = "org.dromara.maxkey.passkey") +@ConditionalOnProperty(prefix = "maxkey.passkey", name = "enabled", havingValue = "true", matchIfMissing = true) +public class PasskeyAutoConfiguration { + private static final Logger _logger = LoggerFactory.getLogger(PasskeyAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public PasskeyService passkeyService(PasskeyProperties passkeyProperties) { + _logger.debug("Creating PasskeyService bean with properties: {}", passkeyProperties.isEnabled()); + return new PasskeyServiceImpl(); + } + + @Bean + @ConditionalOnMissingBean + public PasskeyManager passkeyManager() { + _logger.debug("Creating PasskeyManager bean"); + return new PasskeyManager(); + } + + /** + * 初始化日志 + */ + public PasskeyAutoConfiguration() { + _logger.info("MaxKey Passkey module is being initialized"); + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java new file mode 100644 index 000000000..fd19842cc --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java @@ -0,0 +1,351 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Passkey配置属性 + */ +@ConfigurationProperties(prefix = "maxkey.passkey") +public class PasskeyProperties { + + /** + * 是否启用Passkey功能 + */ + private boolean enabled = true; + + /** + * RP (Relying Party) 配置 + */ + private RelyingParty relyingParty = new RelyingParty(); + + /** + * 认证器配置 + */ + private Authenticator authenticator = new Authenticator(); + + /** + * 挑战配置 + */ + private Challenge challenge = new Challenge(); + + /** + * 用户限制配置 + */ + private UserLimits userLimits = new UserLimits(); + + /** + * 会话配置 + */ + private Session session = new Session(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public RelyingParty getRelyingParty() { + return relyingParty; + } + + public void setRelyingParty(RelyingParty relyingParty) { + this.relyingParty = relyingParty; + } + + public Authenticator getAuthenticator() { + return authenticator; + } + + public void setAuthenticator(Authenticator authenticator) { + this.authenticator = authenticator; + } + + public Challenge getChallenge() { + return challenge; + } + + public void setChallenge(Challenge challenge) { + this.challenge = challenge; + } + + public UserLimits getUserLimits() { + return userLimits; + } + + public void setUserLimits(UserLimits userLimits) { + this.userLimits = userLimits; + } + + public Session getSession() { + return session; + } + + public void setSession(Session session) { + this.session = session; + } + + /** + * RP (Relying Party) 配置 + */ + // 在 RelyingParty 类中添加 + public static class RelyingParty { + /** + * RP名称 + */ + private String name = "MaxKey"; + + /** + * RP ID(通常是域名) + */ + private String id = "localhost"; + + /** + * RP图标URL + */ + private String icon; + + /** + * 允许的 origins 列表 + */ + private java.util.List allowedOrigins = java.util.Arrays.asList("http://localhost:8527", "http://localhost:8080"); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public java.util.List getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(java.util.List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + } + + /** + * 认证器配置 + */ + public static class Authenticator { + /** + * 认证器附件偏好:platform, cross-platform, null + */ + private String attachment = "platform"; + + /** + * 用户验证要求:required, preferred, discouraged + */ + private String userVerification = "required"; + + /** + * 证明偏好:none, indirect, direct + */ + private String attestation = "none"; + + /** + * 是否要求驻留密钥 + */ + private boolean requireResidentKey = false; + + public String getAttachment() { + return attachment; + } + + public void setAttachment(String attachment) { + this.attachment = attachment; + } + + public String getUserVerification() { + return userVerification; + } + + public void setUserVerification(String userVerification) { + this.userVerification = userVerification; + } + + public String getAttestation() { + return attestation; + } + + public void setAttestation(String attestation) { + this.attestation = attestation; + } + + public boolean isRequireResidentKey() { + return requireResidentKey; + } + + public void setRequireResidentKey(boolean requireResidentKey) { + this.requireResidentKey = requireResidentKey; + } + } + + /** + * 挑战配置 + */ + public static class Challenge { + /** + * 挑战长度(字节) + */ + private int length = 32; + + /** + * 挑战过期时间(分钟) + */ + private int expireMinutes = 5; + + /** + * 操作超时时间(毫秒) + */ + private long timeoutMs = 60000; + + /** + * 是否自动清理过期挑战 + */ + private boolean autoCleanup = true; + + /** + * 清理间隔(小时) + */ + private int cleanupIntervalHours = 1; + + public int getLength() { + return length; + } + + public void setLength(int length) { + this.length = length; + } + + public int getExpireMinutes() { + return expireMinutes; + } + + public void setExpireMinutes(int expireMinutes) { + this.expireMinutes = expireMinutes; + } + + public long getTimeoutMs() { + return timeoutMs; + } + + public void setTimeoutMs(long timeoutMs) { + this.timeoutMs = timeoutMs; + } + + public boolean isAutoCleanup() { + return autoCleanup; + } + + public void setAutoCleanup(boolean autoCleanup) { + this.autoCleanup = autoCleanup; + } + + public int getCleanupIntervalHours() { + return cleanupIntervalHours; + } + + public void setCleanupIntervalHours(int cleanupIntervalHours) { + this.cleanupIntervalHours = cleanupIntervalHours; + } + } + + /** + * 用户限制配置 + */ + public static class UserLimits { + /** + * 每个用户最大Passkey数量 + */ + private int maxPasskeysPerUser = 5; + + /** + * 是否允许重复注册相同设备 + */ + private boolean allowDuplicateDevices = false; + + public int getMaxPasskeysPerUser() { + return maxPasskeysPerUser; + } + + public void setMaxPasskeysPerUser(int maxPasskeysPerUser) { + this.maxPasskeysPerUser = maxPasskeysPerUser; + } + + public boolean isAllowDuplicateDevices() { + return allowDuplicateDevices; + } + + public void setAllowDuplicateDevices(boolean allowDuplicateDevices) { + this.allowDuplicateDevices = allowDuplicateDevices; + } + } + + /** + * 会话配置 + */ + public static class Session { + /** + * 认证会话过期时间(分钟) + */ + private int authSessionExpireMinutes = 30; + + /** + * 是否启用会话管理 + */ + private boolean enabled = true; + + public int getAuthSessionExpireMinutes() { + return authSessionExpireMinutes; + } + + public void setAuthSessionExpireMinutes(int authSessionExpireMinutes) { + this.authSessionExpireMinutes = authSessionExpireMinutes; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/WebAuthnConfig.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/WebAuthnConfig.java new file mode 100644 index 000000000..367b9624d --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/WebAuthnConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.config; + +import com.webauthn4j.WebAuthnManager; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.PublicKeyCredentialParameters; +import com.webauthn4j.data.PublicKeyCredentialType; +// import com.webauthn4j.validator.WebAuthnRegistrationContextValidator; +// import com.webauthn4j.validator.WebAuthnAuthenticationContextValidator; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; +import java.util.List; + +/** + * WebAuthn4J 配置类 + */ +@Configuration +@EnableConfigurationProperties(PasskeyProperties.class) +public class WebAuthnConfig { + + /** + * WebAuthn Manager Bean + * 用于处理 WebAuthn 注册和认证的核心组件 + */ + @Bean + public WebAuthnManager webAuthnManager() { + return WebAuthnManager.createNonStrictWebAuthnManager(); + } + + /** + * ObjectConverter Bean + * 用于 WebAuthn 数据的序列化和反序列化 + */ + @Bean + public ObjectConverter objectConverter() { + return new ObjectConverter(); + } + + /** + * 支持的公钥凭据参数 + * 定义支持的算法类型 + */ + @Bean + public List publicKeyCredentialParameters() { + return Arrays.asList( + new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256), + new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256), + new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.PS256) + ); + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/endpoint/PasskeyAuthenticationEndpoint.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/endpoint/PasskeyAuthenticationEndpoint.java new file mode 100644 index 000000000..dc6b7354c --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/endpoint/PasskeyAuthenticationEndpoint.java @@ -0,0 +1,515 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.endpoint; + +import org.dromara.maxkey.passkey.manager.PasskeyManager; +import org.dromara.maxkey.entity.Message; +import org.dromara.maxkey.entity.idm.UserInfo; +import org.dromara.maxkey.persistence.service.UserInfoService; +import org.dromara.maxkey.authn.web.AuthorizationUtils; +import org.dromara.maxkey.authn.session.Session; +import org.dromara.maxkey.authn.SignPrincipal; +import org.dromara.maxkey.authn.realm.AbstractAuthenticationRealm; +import org.dromara.maxkey.authn.session.SessionManager; +import org.dromara.maxkey.authn.jwt.AuthJwt; +import org.dromara.maxkey.authn.jwt.AuthTokenService; +import org.dromara.maxkey.authn.LoginCredential; +import org.dromara.maxkey.util.IdGenerator; +import org.dromara.maxkey.web.WebContext; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.ModelAndView; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +/** + * Passkey认证端点 + * 提供Passkey认证相关的REST API + */ +@RestController +@RequestMapping("/passkey/authentication") +public class PasskeyAuthenticationEndpoint { + private static final Logger _logger = LoggerFactory.getLogger(PasskeyAuthenticationEndpoint.class); + + @Autowired + private PasskeyManager passkeyManager; + + @Autowired + private UserInfoService userInfoService; + + @Autowired + private AbstractAuthenticationRealm authenticationRealm; + + @Autowired + private SessionManager sessionManager; + + @Autowired + private AuthTokenService authTokenService; + + // 管理员权限列表 + public static ArrayList grantedAdministratorsAuthoritys = new ArrayList(); + + static { + grantedAdministratorsAuthoritys.add(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_ADMINISTRATORS")); + } + + /** + * 创建完整的登录会话 + * 参考 AbstractAuthenticationProvider.createOnlineTicket 方法 + * @param userInfo 用户信息 + * @return 认证令牌 + */ + private UsernamePasswordAuthenticationToken createOnlineTicket(UserInfo userInfo) { + try { + // 创建登录凭证 + LoginCredential loginCredential = new LoginCredential(); + loginCredential.setUsername(userInfo.getUsername()); + loginCredential.setPassword(""); // Passkey认证不需要密码 + + + + // 获取用户权限 + List grantedAuthoritys = authenticationRealm.grantAuthority(userInfo); + + // 检查管理员权限 + for(GrantedAuthority administratorsAuthority : grantedAdministratorsAuthoritys) { + if(grantedAuthoritys.contains(administratorsAuthority)) { + grantedAuthoritys.add(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_ALL_USER")); + break; + } + } + + // 创建认证主体 + SignPrincipal signPrincipal = new SignPrincipal(userInfo); + signPrincipal.setAuthenticated(true); + + // 创建认证令牌 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(signPrincipal, "password", grantedAuthoritys); + authenticationToken.setDetails(new WebAuthenticationDetails(WebContext.getRequest())); + + // 创建会话 + IdGenerator idGenerator = new IdGenerator(); + String sessionId = idGenerator.generate(); + Session session = new Session(sessionId, authenticationToken); + session.setLastAccessTime(LocalDateTime.now()); + + // 更新SignPrincipal的会话信息 + signPrincipal.setSessionId(session.getId()); + userInfo.setSessionId(session.getId()); + + // 检查管理员权限 + for (GrantedAuthority administratorsAuthority : grantedAdministratorsAuthoritys) { + if (grantedAuthoritys.contains(administratorsAuthority)) { + signPrincipal.setRoleAdministrators(true); + _logger.trace("ROLE ADMINISTRATORS Authentication ."); + } + } + _logger.debug("Granted Authority {}", grantedAuthoritys); + + // 设置授权应用 + signPrincipal.setGrantedAuthorityApps(authenticationRealm.queryAuthorizedApps(grantedAuthoritys)); + + // 保存会话 + sessionManager.create(session.getId(), session); + + // 将认证信息放入当前会话上下文 + session.setAuthentication(authenticationToken); + + // 设置认证信息到 HTTP 会话 + AuthorizationUtils.setAuthentication(authenticationToken); + + return authenticationToken; + } catch (Exception e) { + _logger.error("创建在线票据失败", e); + return null; + } + } + + /** + * 开始Passkey认证 + * @param request 请求参数 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 认证选项 + */ + @PostMapping("/begin") + public ResponseEntity beginAuthentication( + @RequestBody(required = false) Map request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Begin Passkey authentication request received"); + + try { + String userId = null; + + // 获取用户ID(可选,支持无用户名登录) + if (request != null) { + userId = (String) request.get("userId"); + } + + // 对于无用户名登录,先检查系统中是否有任何可用的 Passkey + if (userId == null || userId.isEmpty()) { + _logger.debug("Checking for registered passkeys in usernameless authentication"); + boolean hasPasskeys = hasAnyRegisteredPasskeys(); + _logger.debug("Has registered passkeys: {}", hasPasskeys); + + // 检查系统中是否有任何注册的 Passkey + if (!hasPasskeys) { + _logger.warn("No Passkeys registered in the system for usernameless authentication"); + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "系统中还没有注册任何 Passkey,请先注册 Passkey 后再使用此功能")); + } else { + _logger.debug("Found registered passkeys, proceeding with authentication"); + } + } + + // 生成认证选项 + Map options = passkeyManager.beginAuthentication(userId); + + // 将认证选项存储到会话中,用于后续验证 + HttpSession session = httpRequest.getSession(); + session.setAttribute("passkey_auth_options", options); + + _logger.info("Passkey authentication options generated for user: {}", userId != null ? userId : "anonymous"); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "认证选项生成成功", options)); + + } catch (RuntimeException e) { + // 处理业务逻辑异常(如用户没有 Passkey) + _logger.warn("Passkey authentication failed: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, e.getMessage())); + } catch (Exception e) { + _logger.error("Error beginning Passkey authentication", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "生成认证选项失败: " + e.getMessage())); + } + } + + /** + * 完成Passkey认证 + * @param request 认证响应数据 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 认证结果 + */ + @PostMapping("/finish") + public ResponseEntity finishAuthentication( + @RequestBody Map request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Finish Passkey authentication request received"); + + try { + // 验证认证响应 + Map result = passkeyManager.finishAuthentication(request); + + if (result != null && Boolean.TRUE.equals(result.get("success"))) { + String userId = (String) result.get("userId"); + String credentialId = (String) result.get("credentialId"); + + // 获取完整的用户信息 + UserInfo userInfo = userInfoService.get(userId); + if (userInfo == null) { + _logger.error("User not found for userId: {}", userId); + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户信息不存在")); + } + + // 创建完整的登录会话 + UsernamePasswordAuthenticationToken authenticationToken = createOnlineTicket(userInfo); + + if (authenticationToken == null) { + _logger.error("Failed to create authentication token for user: {}", userId); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "创建认证会话失败")); + } + + // 获取会话信息 + SignPrincipal principal = (SignPrincipal) authenticationToken.getPrincipal(); + String sessionId = principal.getSessionId(); + + // 生成认证token + AuthJwt authJwtObj = authTokenService.genAuthJwt(authenticationToken); + String authJwt = authJwtObj != null ? authJwtObj.getToken() : null; + + // 设置 Passkey 特有的会话信息 + HttpSession httpSession = httpRequest.getSession(); + httpSession.setAttribute("passkey_authenticated", true); + httpSession.setAttribute("passkey_user_id", userId); + httpSession.setAttribute("passkey_credential_id", credentialId); + httpSession.setAttribute("passkey_auth_time", System.currentTimeMillis()); + + // 清理认证选项 + httpSession.removeAttribute("passkey_auth_options"); + + _logger.info("Passkey authentication completed successfully for user: {} ({})", userInfo.getUsername(), userId); + + // 构建完整的认证结果 + Map responseData = new java.util.HashMap<>(); + responseData.put("userInfo", userInfo); + responseData.put("onlineTicket", sessionId); + + // 关键修改:返回完整的 AuthJwt 对象,而不是只返回 token 字符串 + if (authJwtObj != null) { + // 直接返回完整的 AuthJwt 对象,包含所有必要的字段 + responseData.put("id", authJwtObj.getId()); + responseData.put("username", authJwtObj.getUsername()); + responseData.put("displayName", authJwtObj.getDisplayName()); + responseData.put("email", authJwtObj.getEmail()); + responseData.put("token", authJwtObj.getToken()); + responseData.put("ticket", authJwtObj.getTicket()); + responseData.put("authorities", authJwtObj.getAuthorities()); // 关键:包含权限信息 + responseData.put("passwordSetType", authJwtObj.getPasswordSetType()); + responseData.put("remeberMe", authJwtObj.getRemeberMe()); + responseData.put("expiresIn", authJwtObj.getExpiresIn()); + responseData.put("refreshToken", authJwtObj.getRefreshToken()); + responseData.put("instId", authJwtObj.getInstId()); + responseData.put("instName", authJwtObj.getInstName()); + } else { + // 如果 authJwtObj 为空,至少设置基本信息 + responseData.put("id", userId); + responseData.put("username", userInfo.getUsername()); + responseData.put("displayName", userInfo.getDisplayName()); + responseData.put("email", userInfo.getEmail()); + responseData.put("authorities", new ArrayList<>()); // 空权限列表 + } + responseData.put("userId", userId); + responseData.put("authTime", System.currentTimeMillis()); + + // 检查是否有重定向URL + String redirectUrl = (String) httpSession.getAttribute("redirect_url"); + if (redirectUrl != null) { + httpSession.removeAttribute("redirect_url"); + responseData.put("redirectUrl", redirectUrl); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "Passkey认证成功,即将跳转", responseData)); + } else { + responseData.put("redirectUrl", "/index"); // 默认跳转到首页 + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "Passkey认证成功", responseData)); + } + + } else { + _logger.warn("Passkey authentication failed"); + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "Passkey认证失败,请重试")); + } + + } catch (Exception e) { + _logger.error("Error finishing Passkey authentication", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "认证验证失败: " + e.getMessage())); + } + } + + /** + * 检查系统中是否有任何注册的 Passkey + * @return 如果系统中有注册的 Passkey 返回 true,否则返回 false + */ + private boolean hasAnyRegisteredPasskeys() { + try { + // 通过 PasskeyManager 检查是否有任何用户注册了 Passkey + // 这里可以调用一个统计方法或者查询方法 + return passkeyManager.hasAnyRegisteredPasskeys(); + } catch (Exception e) { + _logger.error("Error checking for registered passkeys", e); + return false; // 出错时保守返回 false + } + } + + /** + * 检查认证状态 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 认证状态 + */ + @GetMapping("/status") + public ResponseEntity getAuthenticationStatus( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Get Passkey authentication status request received"); + + try { + HttpSession session = httpRequest.getSession(false); + + Map status = new java.util.HashMap<>(); + + if (session != null) { + Boolean authenticated = (Boolean) session.getAttribute("passkey_authenticated"); + String userId = (String) session.getAttribute("passkey_user_id"); + Long authTime = (Long) session.getAttribute("passkey_auth_time"); + + status.put("authenticated", authenticated != null && authenticated); + status.put("userId", userId); + status.put("authTime", authTime); + + // 检查认证是否过期(30分钟) + if (authTime != null) { + long currentTime = System.currentTimeMillis(); + long authDuration = currentTime - authTime; + boolean expired = authDuration > 30 * 60 * 1000; // 30分钟 + + status.put("expired", expired); + status.put("remainingTime", expired ? 0 : (30 * 60 * 1000 - authDuration)); + + if (expired) { + // 清理过期的认证信息 + session.removeAttribute("passkey_authenticated"); + session.removeAttribute("passkey_user_id"); + session.removeAttribute("passkey_credential_id"); + session.removeAttribute("passkey_auth_time"); + + status.put("authenticated", false); + status.put("userId", null); + status.put("authTime", null); + } + } else { + status.put("expired", false); + status.put("remainingTime", 0); + } + } else { + status.put("authenticated", false); + status.put("userId", null); + status.put("authTime", null); + status.put("expired", false); + status.put("remainingTime", 0); + } + + _logger.debug("Passkey authentication status: {}", status.get("authenticated")); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "获取状态成功", status)); + + } catch (Exception e) { + _logger.error("Error getting Passkey authentication status", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "获取认证状态失败: " + e.getMessage())); + } + } + + /** + * 注销Passkey认证 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 注销结果 + */ + @PostMapping("/logout") + public ResponseEntity logout( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Passkey logout request received"); + + try { + HttpSession session = httpRequest.getSession(false); + + if (session != null) { + String userId = (String) session.getAttribute("passkey_user_id"); + + // 清理所有Passkey相关的会话信息 + session.removeAttribute("passkey_authenticated"); + session.removeAttribute("passkey_user_id"); + session.removeAttribute("passkey_credential_id"); + session.removeAttribute("passkey_auth_time"); + session.removeAttribute("passkey_auth_options"); + + _logger.info("Passkey logout completed for user: {}", userId); + } + + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "注销成功")); + + } catch (Exception e) { + _logger.error("Error during Passkey logout", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "注销失败: " + e.getMessage())); + } + } + + /** + * 验证当前会话的Passkey认证状态 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 验证结果 + */ + @PostMapping("/verify") + public ResponseEntity verifyAuthentication( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Verify Passkey authentication request received"); + + try { + HttpSession session = httpRequest.getSession(false); + + if (session == null) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "会话不存在")); + } + + Boolean authenticated = (Boolean) session.getAttribute("passkey_authenticated"); + String userId = (String) session.getAttribute("passkey_user_id"); + Long authTime = (Long) session.getAttribute("passkey_auth_time"); + + if (authenticated == null || !authenticated || userId == null || authTime == null) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "未认证或认证信息不完整")); + } + + // 检查认证是否过期 + long currentTime = System.currentTimeMillis(); + long authDuration = currentTime - authTime; + boolean expired = authDuration > 30 * 60 * 1000; // 30分钟 + + if (expired) { + // 清理过期的认证信息 + session.removeAttribute("passkey_authenticated"); + session.removeAttribute("passkey_user_id"); + session.removeAttribute("passkey_credential_id"); + session.removeAttribute("passkey_auth_time"); + + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "认证已过期")); + } + + Map result = new java.util.HashMap<>(); + result.put("valid", true); + result.put("userId", userId); + result.put("authTime", authTime); + result.put("remainingTime", 30 * 60 * 1000 - authDuration); + + _logger.debug("Passkey authentication verified for user: {}", userId); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "认证有效", result)); + + } catch (Exception e) { + _logger.error("Error verifying Passkey authentication", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "验证认证状态失败: " + e.getMessage())); + } + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/endpoint/PasskeyRegistrationEndpoint.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/endpoint/PasskeyRegistrationEndpoint.java new file mode 100644 index 000000000..01cdbff0e --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/endpoint/PasskeyRegistrationEndpoint.java @@ -0,0 +1,302 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.endpoint; + +import org.dromara.maxkey.passkey.manager.PasskeyManager; +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.maxkey.entity.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Passkey注册端点 + * 提供Passkey注册相关的REST API + */ +@RestController +@RequestMapping("/passkey/registration") +public class PasskeyRegistrationEndpoint { + private static final Logger _logger = LoggerFactory.getLogger(PasskeyRegistrationEndpoint.class); + + @Autowired + private PasskeyManager passkeyManager; + + /** + * 开始Passkey注册 + * @param request 请求参数 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 注册选项 + */ + @PostMapping("/begin") + public ResponseEntity beginRegistration( + @RequestBody Map request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Begin Passkey registration request received"); + + try { + // 获取请求参数 + String userId = (String) request.get("userId"); + String username = (String) request.get("username"); + String displayName = (String) request.get("displayName"); + + // 参数验证 + if (userId == null || userId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户ID不能为空")); + } + + if (username == null || username.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户名不能为空")); + } + + if (displayName == null || displayName.trim().isEmpty()) { + displayName = username; // 默认使用用户名作为显示名称 + } + + // 生成注册选项 + Map options = passkeyManager.beginRegistration(userId, username, displayName); + + _logger.info("Passkey registration options generated for user: {}", userId); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "注册选项生成成功", options)); + + } catch (Exception e) { + _logger.error("Error beginning Passkey registration", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "生成注册选项失败: " + e.getMessage())); + } + } + + /** + * 完成Passkey注册 + * @param request 注册响应数据 + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 注册结果 + */ + @PostMapping("/finish") + public ResponseEntity finishRegistration( + @RequestBody Map request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Finish Passkey registration request received"); + + try { + // 获取用户ID + String userId = (String) request.get("userId"); + if (userId == null || userId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户ID不能为空")); + } + + // 验证注册响应 + UserPasskey newPasskey = passkeyManager.finishRegistration(userId, request); + + if (newPasskey != null) { + _logger.info("Passkey registration completed successfully for user: {}", userId); + + // 构建返回的Passkey信息 + Map passkeyInfo = new HashMap<>(); + passkeyInfo.put("id", newPasskey.getId()); + passkeyInfo.put("credentialId", newPasskey.getCredentialId()); + passkeyInfo.put("displayName", newPasskey.getDisplayName()); + passkeyInfo.put("deviceType", newPasskey.getDeviceType()); + passkeyInfo.put("signatureCount", newPasskey.getSignatureCount()); + passkeyInfo.put("createdDate", newPasskey.getCreatedDate()); + passkeyInfo.put("lastUsedDate", newPasskey.getLastUsedDate()); + passkeyInfo.put("status", newPasskey.getStatus()); + + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "Passkey注册成功", passkeyInfo)); + } else { + _logger.warn("Passkey registration failed for user: {}", userId); + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "Passkey注册失败,请重试")); + } + + } catch (Exception e) { + _logger.error("Error finishing Passkey registration", e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "注册验证失败: " + e.getMessage())); + } + } + + /** + * 获取用户的Passkey列表 + * @param userId 用户ID + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return Passkey列表 + */ + @GetMapping("/list/{userId}") + public ResponseEntity getUserPasskeys( + @PathVariable String userId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Get user Passkeys request for user: {}", userId); + + try { + if (userId == null || userId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户ID不能为空")); + } + + List passkeys = passkeyManager.getUserPasskeys(userId); + + // 移除敏感信息 + passkeys.forEach(passkey -> { + passkey.setPublicKey(null); // 不返回公钥信息 + }); + + _logger.debug("Retrieved {} Passkeys for user: {}", passkeys.size(), userId); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "获取成功", passkeys)); + + } catch (Exception e) { + _logger.error("Error getting user Passkeys for user: {}", userId, e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "获取Passkey列表失败: " + e.getMessage())); + } + } + + /** + * 删除用户的Passkey + * @param userId 用户ID + * @param credentialId 凭据ID + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 删除结果 + */ + @DeleteMapping("/delete/{userId}/{credentialId}") + public ResponseEntity deletePasskey( + @PathVariable String userId, + @PathVariable String credentialId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Delete Passkey request for user: {}, credentialId: {}", userId, credentialId); + + try { + if (userId == null || userId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户ID不能为空")); + } + + if (credentialId == null || credentialId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "凭据ID不能为空")); + } + + boolean success = passkeyManager.deleteUserPasskey(userId, credentialId); + + if (success) { + _logger.info("Passkey deleted successfully for user: {}", userId); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "Passkey删除成功")); + } else { + _logger.warn("Failed to delete Passkey for user: {}", userId); + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "Passkey删除失败")); + } + + } catch (Exception e) { + _logger.error("Error deleting Passkey for user: {}", userId, e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "删除Passkey失败: " + e.getMessage())); + } + } + + /** + * 获取Passkey统计信息 + * @param userId 用户ID + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 统计信息 + */ + @GetMapping("/stats/{userId}") + public ResponseEntity getPasskeyStats( + @PathVariable String userId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Get Passkey stats request for user: {}", userId); + + try { + if (userId == null || userId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户ID不能为空")); + } + + Map stats = passkeyManager.getPasskeyStats(userId); + + _logger.debug("Retrieved Passkey stats for user: {}", userId); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "获取成功", stats)); + + } catch (Exception e) { + _logger.error("Error getting Passkey stats for user: {}", userId, e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "获取统计信息失败: " + e.getMessage())); + } + } + + /** + * 检查用户是否支持Passkey + * @param userId 用户ID + * @param httpRequest HTTP请求 + * @param httpResponse HTTP响应 + * @return 支持状态 + */ + @GetMapping("/support/{userId}") + public ResponseEntity checkPasskeySupport( + @PathVariable String userId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + _logger.debug("Check Passkey support request for user: {}", userId); + + try { + if (userId == null || userId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(new Message<>(Message.ERROR, "用户ID不能为空")); + } + + boolean supported = passkeyManager.isPasskeySupported(userId); + + Map result = new java.util.HashMap<>(); + result.put("supported", supported); + + _logger.debug("Passkey support check for user: {}, result: {}", userId, supported); + return ResponseEntity.ok(new Message<>(Message.SUCCESS, "检查完成", result)); + + } catch (Exception e) { + _logger.error("Error checking Passkey support for user: {}", userId, e); + return ResponseEntity.internalServerError() + .body(new Message<>(Message.ERROR, "检查支持状态失败: " + e.getMessage())); + } + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/manager/PasskeyManager.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/manager/PasskeyManager.java new file mode 100644 index 000000000..424485411 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/manager/PasskeyManager.java @@ -0,0 +1,227 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.manager; + +import org.dromara.maxkey.passkey.service.PasskeyService; +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Passkey管理器 + * 负责协调Passkey相关的业务逻辑和WebAuthn操作 + */ +@Component +public class PasskeyManager { + private static final Logger _logger = LoggerFactory.getLogger(PasskeyManager.class); + + @Autowired + private PasskeyService passkeyService; + + /** + * 开始Passkey注册流程 + * @param userId 用户ID + * @param username 用户名 + * @param displayName 显示名称 + * @return 注册选项 + */ + public Map beginRegistration(String userId, String username, String displayName) { + _logger.debug("Beginning Passkey registration for user: {}", userId); + + try { + // 检查用户是否已有Passkey + List existingPasskeys = passkeyService.getUserPasskeys(userId); + if (existingPasskeys.size() >= 5) { // 限制每个用户最多5个Passkey + _logger.warn("User {} already has maximum number of Passkeys", userId); + throw new RuntimeException("用户已达到Passkey数量上限"); + } + + return passkeyService.generateRegistrationOptions(userId, username, displayName); + + } catch (Exception e) { + _logger.error("Error beginning registration for user: {}", userId, e); + throw new RuntimeException("开始注册失败: " + e.getMessage(), e); + } + } + + /** + * 完成Passkey注册 + * @param userId 用户ID + * @param registrationResponse 注册响应 + * @return 注册成功时返回新创建的UserPasskey对象,失败时返回null + */ + public UserPasskey finishRegistration(String userId, Map registrationResponse) { + _logger.debug("Finishing Passkey registration for user: {}", userId); + + try { + UserPasskey newPasskey = passkeyService.verifyRegistrationResponse(userId, registrationResponse); + + if (newPasskey != null) { + _logger.info("Passkey registration completed successfully for user: {}", userId); + } else { + _logger.warn("Passkey registration failed for user: {}", userId); + } + + return newPasskey; + + } catch (Exception e) { + _logger.error("Error finishing registration for user: {}", userId, e); + return null; + } + } + + /** + * 开始Passkey认证流程 + * @param userId 用户ID(可选,为空时支持无用户名登录) + * @return 认证选项 + */ + public Map beginAuthentication(String userId) { + _logger.debug("Beginning Passkey authentication for user: {}", userId); + + try { + // 如果指定了用户ID,检查用户是否有可用的Passkey + if (userId != null && !userId.isEmpty()) { + List userPasskeys = passkeyService.getUserPasskeys(userId); + if (userPasskeys.isEmpty()) { + _logger.warn("No active Passkeys found for user: {}", userId); + throw new RuntimeException("用户没有可用的Passkey"); + } + } + + return passkeyService.generateAuthenticationOptions(userId); + + } catch (Exception e) { + _logger.error("Error beginning authentication for user: {}", userId, e); + throw new RuntimeException("开始认证失败: " + e.getMessage(), e); + } + } + + /** + * 完成Passkey认证 + * @param authenticationResponse 认证响应 + * @return 认证结果,包含用户信息 + */ + public Map finishAuthentication(Map authenticationResponse) { + _logger.debug("Finishing Passkey authentication"); + + try { + Map result = passkeyService.verifyAuthenticationResponse(authenticationResponse); + + if (result != null && Boolean.TRUE.equals(result.get("success"))) { + String userId = (String) result.get("userId"); + _logger.info("Passkey authentication completed successfully for user: {}", userId); + } else { + _logger.warn("Passkey authentication failed"); + } + + return result; + + } catch (Exception e) { + _logger.error("Error finishing authentication", e); + return null; + } + } + + /** + * 获取用户的所有Passkey + * @param userId 用户ID + * @return Passkey列表 + */ + public List getUserPasskeys(String userId) { + _logger.debug("Getting Passkeys for user: {}", userId); + return passkeyService.getUserPasskeys(userId); + } + + /** + * 删除用户的Passkey + * @param userId 用户ID + * @param credentialId 凭据ID + * @return 是否成功 + */ + public boolean deleteUserPasskey(String userId, String credentialId) { + _logger.debug("Deleting Passkey for user: {}, credentialId: {}", userId, credentialId); + + try { + boolean success = passkeyService.deletePasskey(userId, credentialId); + + if (success) { + _logger.info("Passkey deleted successfully for user: {}", userId); + } else { + _logger.warn("Failed to delete Passkey for user: {}", userId); + } + + return success; + + } catch (Exception e) { + _logger.error("Error deleting Passkey for user: {}", userId, e); + return false; + } + } + + /** + * 检查用户是否支持Passkey + * @param userId 用户ID + * @return 是否支持 + */ + public boolean isPasskeySupported(String userId) { + // 这里可以添加更多的检查逻辑,比如用户设置、设备能力等 + return true; + } + + /** + * 获取Passkey统计信息 + * @param userId 用户ID + * @return 统计信息 + */ + public Map getPasskeyStats(String userId) { + List passkeys = passkeyService.getUserPasskeys(userId); + + Map stats = new java.util.HashMap<>(); + stats.put("totalCount", passkeys.size()); + stats.put("maxAllowed", 5); + stats.put("canAddMore", passkeys.size() < 5); + + return stats; + } + + /** + * 清理过期的挑战 + * @return 清理的数量 + */ + public int cleanExpiredChallenges() { + _logger.debug("Cleaning expired challenges"); + return passkeyService.cleanExpiredChallenges(); + } + + /** + * 检查系统中是否有任何注册的 Passkey + * @return 如果系统中有注册的 Passkey 返回 true,否则返回 false + */ + public boolean hasAnyRegisteredPasskeys() { + try { + return passkeyService.hasAnyRegisteredPasskeys(); + } catch (Exception e) { + _logger.error("Error checking for any registered passkeys", e); + return false; + } + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/service/PasskeyService.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/service/PasskeyService.java new file mode 100644 index 000000000..21b289086 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/service/PasskeyService.java @@ -0,0 +1,122 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.service; + +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.maxkey.entity.passkey.PasskeyChallenge; +import java.util.List; +import java.util.Map; + +/** + * Passkey服务接口 + */ +public interface PasskeyService { + + /** + * 生成注册选项 + * @param userId 用户ID + * @param username 用户名 + * @param displayName 显示名称 + * @return 注册选项JSON + */ + Map generateRegistrationOptions(String userId, String username, String displayName); + + /** + * 验证注册响应 + * @param userId 用户ID + * @param registrationResponse 注册响应JSON + * @return 验证成功时返回新创建的UserPasskey对象,失败时返回null + */ + UserPasskey verifyRegistrationResponse(String userId, Map registrationResponse); + + /** + * 生成认证选项 + * @param userId 用户ID(可选,为空时返回所有可用凭据) + * @return 认证选项JSON + */ + Map generateAuthenticationOptions(String userId); + + /** + * 验证认证响应 + * @param authenticationResponse 认证响应JSON + * @return 验证结果,包含用户ID + */ + Map verifyAuthenticationResponse(Map authenticationResponse); + + /** + * 获取用户的所有Passkey凭据 + * @param userId 用户ID + * @return Passkey凭据列表 + */ + List getUserPasskeys(String userId); + + /** + * 删除Passkey凭据 + * @param userId 用户ID + * @param credentialId 凭据ID + * @return 删除结果 + */ + boolean deletePasskey(String userId, String credentialId); + + /** + * 保存Passkey凭据 + * @param userPasskey Passkey凭据 + * @return 保存结果 + */ + boolean savePasskey(UserPasskey userPasskey); + + /** + * 根据凭据ID获取Passkey + * @param credentialId 凭据ID + * @return Passkey凭据 + */ + UserPasskey getPasskeyByCredentialId(String credentialId); + + /** + * 更新签名计数 + * @param credentialId 凭据ID + * @param signatureCount 新的签名计数 + * @return 更新结果 + */ + boolean updateSignatureCount(String credentialId, Long signatureCount); + + /** + * 保存挑战信息 + * @param challenge 挑战信息 + * @return 保存结果 + */ + boolean saveChallenge(PasskeyChallenge challenge); + + /** + * 获取挑战信息 + * @param challengeId 挑战ID + * @return 挑战信息 + */ + PasskeyChallenge getChallenge(String challengeId); + + /** + * 删除过期的挑战信息 + * @return 删除的记录数 + */ + int cleanExpiredChallenges(); + + /** + * 检查系统中是否有任何注册的 Passkey + * @return 如果系统中有注册的 Passkey 返回 true,否则返回 false + */ + boolean hasAnyRegisteredPasskeys(); +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/service/impl/PasskeyServiceImpl.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/service/impl/PasskeyServiceImpl.java new file mode 100644 index 000000000..9ddb7a4c9 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/service/impl/PasskeyServiceImpl.java @@ -0,0 +1,774 @@ +/* + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.service.impl; + +import org.dromara.maxkey.passkey.service.PasskeyService; +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.maxkey.entity.passkey.PasskeyChallenge; +import org.dromara.maxkey.passkey.config.PasskeyProperties; +import org.dromara.maxkey.persistence.service.UserPasskeyService; +import org.dromara.maxkey.persistence.service.PasskeyChallengeService; +import org.dromara.maxkey.util.IdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; + +// WebAuthn4J imports +import com.webauthn4j.WebAuthnManager; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.*; +import com.webauthn4j.data.client.*; +import com.webauthn4j.data.attestation.*; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.converter.exception.DataConversionException; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.AuthenticationData; +import com.webauthn4j.data.AuthenticationParameters; +import com.webauthn4j.verifier.exception.VerificationException; +import com.webauthn4j.credential.CredentialRecord; +import com.webauthn4j.credential.CredentialRecordImpl; +import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; +import com.webauthn4j.data.attestation.authenticator.COSEKey; +import com.webauthn4j.data.attestation.authenticator.AAGUID; + +// Passkey utility imports +import org.dromara.maxkey.passkey.util.PasskeyUtils; + +import java.util.*; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.commons.codec.binary.Base64; +import java.util.Objects; + +/** + * Passkey服务实现类 - 重构版本 + * 通过方法拆分和工具类提取,提高代码可维护性和可读性 + */ +@Service +public class PasskeyServiceImpl implements PasskeyService { + private static final Logger _logger = LoggerFactory.getLogger(PasskeyServiceImpl.class); + + // 常量定义 + private static final String CHALLENGE_TYPE_REGISTRATION = "REGISTRATION"; + private static final String CHALLENGE_TYPE_AUTHENTICATION = "AUTHENTICATION"; + private static final String CREDENTIAL_TYPE_PUBLIC_KEY = "public-key"; + private static final String DEFAULT_INST_ID = "1"; + private static final String DEFAULT_DEVICE_NAME = "Passkey 设备"; + + @Autowired + private WebAuthnManager webAuthnManager; + + @Autowired + private ObjectConverter objectConverter; + + @Autowired + private PasskeyProperties passkeyProperties; + + @Autowired + private List publicKeyCredentialParameters; + + @Autowired + private UserPasskeyService userPasskeyService; + + @Autowired + private PasskeyChallengeService passkeyChallengeService; + + private final SecureRandom secureRandom = new SecureRandom(); + private final IdGenerator idGenerator = new IdGenerator(); + + @Override + public Map generateRegistrationOptions(String userId, String username, String displayName) { + _logger.debug("Generating registration options for user: {}", userId); + + try { + // 生成并保存挑战 + String challengeId = generateAndSaveChallenge(userId, CHALLENGE_TYPE_REGISTRATION); + String challengeBase64 = getChallenge(challengeId).getChallenge(); + + // 构建注册选项 + Map options = buildRegistrationOptions(userId, username, displayName, challengeId, challengeBase64); + + _logger.debug("Registration options generated successfully for user: {}", userId); + return options; + + } catch (Exception e) { + _logger.error("Error generating registration options for user: {}", userId, e); + return null; + } + } + + /** + * 生成并保存挑战 + */ + private String generateAndSaveChallenge(String userId, String challengeType) { + PasskeyChallenge passkeyChallenge = PasskeyUtils.generateChallenge( + userId, challengeType, passkeyProperties.getChallenge().getLength()); + passkeyChallenge.setInstId(DEFAULT_INST_ID); + passkeyChallengeService.saveChallenge(passkeyChallenge); + + return passkeyChallenge.getId(); + } + + /** + * 构建注册选项 + */ + private Map buildRegistrationOptions(String userId, String username, String displayName, + String challengeId, String challengeBase64) { + Map options = new HashMap<>(); + options.put("challenge", challengeBase64); + options.put("challengeId", challengeId); + options.put("timeout", passkeyProperties.getChallenge().getTimeoutMs()); + options.put("attestation", passkeyProperties.getAuthenticator().getAttestation()); + + // RP信息 + options.put("rp", buildRelyingPartyInfo()); + + // 用户信息 + options.put("user", buildUserInfo(userId, username, displayName)); + + // 公钥凭据参数 + options.put("pubKeyCredParams", buildPublicKeyCredentialParams()); + + // 认证器选择标准 + options.put("authenticatorSelection", buildAuthenticatorSelection()); + + // 排除凭据 + List> excludeCredentials = buildExcludeCredentials(userId); + if (!excludeCredentials.isEmpty()) { + options.put("excludeCredentials", excludeCredentials); + } + + return options; + } + + /** + * 构建RP信息 + */ + private Map buildRelyingPartyInfo() { + return PasskeyUtils.buildRelyingPartyInfo(passkeyProperties.getRelyingParty()); + } + + /** + * 构建用户信息 + */ + private Map buildUserInfo(String userId, String username, String displayName) { + return PasskeyUtils.buildUserInfo(userId, username, displayName); + } + + /** + * 构建公钥凭据参数 + */ + private List> buildPublicKeyCredentialParams() { + return PasskeyUtils.buildPublicKeyCredentialParams(publicKeyCredentialParameters); + } + + /** + * 构建认证器选择标准 + */ + private Map buildAuthenticatorSelection() { + return PasskeyUtils.buildAuthenticatorSelection(passkeyProperties.getAuthenticator()); + } + + /** + * 构建排除凭据列表 + */ + private List> buildExcludeCredentials(String userId) { + List existingPasskeys = userPasskeyService.findByUserId(userId); + return PasskeyUtils.buildCredentialList(existingPasskeys); + } + + @Override + public UserPasskey verifyRegistrationResponse(String userId, Map registrationResponse) { + _logger.debug("Verifying registration response for user: {}", userId); + + try { + // 验证挑战 + PasskeyChallenge challenge = validateChallenge(registrationResponse, CHALLENGE_TYPE_REGISTRATION); + if (challenge == null) { + _logger.warn("Invalid or expired registration challenge for user: {}", userId); + return null; + } + + // 解析注册响应数据 + RegistrationResponseData responseData = parseRegistrationResponse(registrationResponse); + if (responseData == null) { + _logger.warn("Failed to parse registration response for user: {}", userId); + return null; + } + + // 创建服务器属性 + ServerProperty serverProperty = createServerProperty(responseData.clientDataJSON, challenge.getChallenge()); + if (serverProperty == null) { + return null; + } + + // 执行WebAuthn验证 + RegistrationData registrationData = performRegistrationVerification(responseData, serverProperty); + if (registrationData == null) { + return null; + } + + // 创建并保存Passkey + UserPasskey userPasskey = createUserPasskey(userId, responseData.credentialIdBase64, registrationData); + boolean saved = savePasskey(userPasskey); + + // 标记挑战为已使用 + challenge.setStatus(1); + passkeyChallengeService.saveChallenge(challenge); + + _logger.debug("Registration verification completed for user: {}, result: {}", userId, saved); + return saved ? userPasskey : null; + + } catch (VerificationException e) { + _logger.error("WebAuthn validation failed for user: {}", userId, e); + return null; + } catch (Exception e) { + _logger.error("Error verifying registration response for user: {}", userId, e); + return null; + } + } + + /** + * 注册响应数据结构 + */ + private static class RegistrationResponseData { + String credentialIdBase64; + String attestationObjectBase64; + String clientDataJSONBase64; + byte[] clientDataJSON; + } + + /** + * 验证挑战 + */ + private PasskeyChallenge validateChallenge(Map response, String expectedType) { + String challengeId = (String) response.get("challengeId"); + _logger.debug("Validating challenge with ID: {} and expected type: {}", challengeId, expectedType); + + PasskeyChallenge challenge = passkeyChallengeService.findByChallengeId(challengeId); + + if (challenge == null) { + _logger.warn("Challenge not found for ID: {}", challengeId); + return null; + } + + _logger.debug("Challenge found: {}", challenge.toString()); + _logger.debug("Challenge expired: {}, Challenge type: {}, Expected type: {}, Status: {}", + challenge.isExpired(), challenge.getChallengeType(), expectedType, challenge.getStatus()); + + if (challenge.isExpired()) { + _logger.warn("Challenge expired for ID: {}", challengeId); + return null; + } + + if (!expectedType.equals(challenge.getChallengeType())) { + _logger.warn("Challenge type mismatch for ID: {}. Expected: {}, Actual: {}", + challengeId, expectedType, challenge.getChallengeType()); + return null; + } + + if (challenge.getStatus() != null && challenge.getStatus() != 0) { + _logger.warn("Challenge already used or expired for ID: {}. Status: {}", challengeId, challenge.getStatus()); + return null; + } + + _logger.debug("Challenge validation successful for ID: {}", challengeId); + return challenge; + } + + /** + * 解析注册响应 + */ + private RegistrationResponseData parseRegistrationResponse(Map registrationResponse) { + String credentialIdBase64 = (String) registrationResponse.get("credentialId"); + String attestationObjectBase64 = (String) registrationResponse.get("attestationObject"); + String clientDataJSONBase64 = (String) registrationResponse.get("clientDataJSON"); + + if (credentialIdBase64 == null || attestationObjectBase64 == null || clientDataJSONBase64 == null) { + return null; + } + + RegistrationResponseData data = new RegistrationResponseData(); + data.credentialIdBase64 = credentialIdBase64; + data.attestationObjectBase64 = attestationObjectBase64; + data.clientDataJSONBase64 = clientDataJSONBase64; + data.clientDataJSON = Base64.decodeBase64(clientDataJSONBase64); + + return data; + } + + /** + * 创建服务器属性 + */ + private ServerProperty createServerProperty(byte[] clientDataJSON, String challengeBase64) { + return PasskeyUtils.createServerProperty( + clientDataJSON, challengeBase64, passkeyProperties.getRelyingParty(), objectConverter); + } + + /** + * 执行注册验证 + */ + private RegistrationData performRegistrationVerification(RegistrationResponseData responseData, ServerProperty serverProperty) { + try { + RegistrationParameters registrationParameters = new RegistrationParameters( + serverProperty, + publicKeyCredentialParameters, + false, // userVerificationRequired + true // userPresenceRequired + ); + + String registrationResponseJSON = objectConverter.getJsonConverter().writeValueAsString( + Map.of( + "id", responseData.credentialIdBase64, + "rawId", responseData.credentialIdBase64, + "response", Map.of( + "attestationObject", responseData.attestationObjectBase64, + "clientDataJSON", responseData.clientDataJSONBase64 + ), + "type", CREDENTIAL_TYPE_PUBLIC_KEY + ) + ); + + RegistrationData registrationData = webAuthnManager.parseRegistrationResponseJSON(registrationResponseJSON); + webAuthnManager.verify(registrationData, registrationParameters); + + return registrationData; + + } catch (Exception e) { + _logger.error("Registration verification failed: {}", e.getMessage(), e); + return null; + } + } + + /** + * 创建UserPasskey对象 + */ + private UserPasskey createUserPasskey(String userId, String credentialIdBase64, RegistrationData registrationData) { + UserPasskey userPasskey = new UserPasskey(); + userPasskey.setId(idGenerator.generate()); + userPasskey.setUserId(userId); + userPasskey.setCredentialId(credentialIdBase64); + + // 保存公钥信息 + AttestedCredentialData attestedCredentialData = registrationData.getAttestationObject() + .getAuthenticatorData().getAttestedCredentialData(); + if (attestedCredentialData != null) { + try { + userPasskey.setPublicKey(Base64.encodeBase64String( + objectConverter.getCborConverter().writeValueAsBytes(attestedCredentialData.getCOSEKey()) + )); + } catch (Exception e) { + _logger.error("Failed to encode public key: {}", e.getMessage(), e); + } + } + + userPasskey.setDisplayName(DEFAULT_DEVICE_NAME); + userPasskey.setDeviceType(passkeyProperties.getAuthenticator().getAttachment()); + userPasskey.setInstId(DEFAULT_INST_ID); + userPasskey.setCreatedDate(new Date()); + userPasskey.setLastUsedDate(new Date()); + userPasskey.setSignatureCount(registrationData.getAttestationObject() + .getAuthenticatorData().getSignCount()); + + return userPasskey; + } + + @Override + public Map generateAuthenticationOptions(String userId) { + _logger.debug("Generating authentication options for usernameless authentication"); + + try { + // 生成挑战 + byte[] challenge = new byte[passkeyProperties.getChallenge().getLength()]; + secureRandom.nextBytes(challenge); + String challengeBase64 = Base64.encodeBase64URLSafeString(challenge); + + // 保存挑战信息 - 仅支持无用户名登录 + String challengeId = new IdGenerator().generate(); + PasskeyChallenge passkeyChallenge = new PasskeyChallenge(challengeId, challengeBase64, "AUTHENTICATION"); + passkeyChallenge.setUserId(null); // 无用户名登录,userId 设为 null + passkeyChallenge.setInstId("1"); + passkeyChallengeService.saveChallenge(passkeyChallenge); + + // 构建认证选项 + Map options = new HashMap<>(); + options.put("challenge", challengeBase64); + options.put("challengeId", challengeId); + options.put("timeout", passkeyProperties.getChallenge().getTimeoutMs()); + options.put("rpId", passkeyProperties.getRelyingParty().getId()); + options.put("userVerification", passkeyProperties.getAuthenticator().getUserVerification()); + + // 无用户名登录:不设置 allowCredentials,让认证器自动选择 + _logger.debug("Generated options for usernameless authentication"); + + return options; + + } catch (Exception e) { + _logger.error("Error generating authentication options for usernameless authentication", e); + return null; + } + } + + @Override + public Map verifyAuthenticationResponse(Map authenticationResponse) { + _logger.debug("Verifying authentication response"); + + try { + // 验证挑战 + PasskeyChallenge challenge = validateChallenge(authenticationResponse, CHALLENGE_TYPE_AUTHENTICATION); + if (challenge == null) { + _logger.warn("Invalid or expired authentication challenge"); + return null; + } + + // 解析认证响应数据 + AuthenticationResponseData responseData = parseAuthenticationResponse(authenticationResponse); + if (responseData == null) { + _logger.warn("Failed to parse authentication response"); + return null; + } + + // 获取Passkey凭据 + _logger.debug("Looking for passkey with credential ID: {}", responseData.credentialIdBase64); + + // 先查询所有的Passkey来调试credential ID和status问题 + List allPasskeys = userPasskeyService.findAll(); + _logger.debug("=== CREDENTIAL ID AND STATUS DEBUG ==="); + _logger.debug("Client sent credential ID: {}", responseData.credentialIdBase64); + _logger.debug("Total passkeys in database: {}", allPasskeys.size()); + for (UserPasskey pk : allPasskeys) { + _logger.debug("DB credential ID: {} (user: {}, status: {})", pk.getCredentialId(), pk.getUserId(), pk.getStatus()); + _logger.debug("Match check: {}", pk.getCredentialId().equals(responseData.credentialIdBase64)); + } + _logger.debug("=== END CREDENTIAL ID AND STATUS DEBUG ==="); + + UserPasskey passkey = userPasskeyService.findByCredentialId(responseData.credentialIdBase64); + if (passkey == null) { + _logger.warn("Passkey not found for credential ID: {}", responseData.credentialIdBase64); + return null; + } + + // 验证挑战与用户匹配 + if (!validateChallengeUserMatch(challenge, passkey)) { + return null; + } + + // 创建服务器属性 + ServerProperty serverProperty = createServerProperty(responseData.clientDataJSON, challenge.getChallenge()); + if (serverProperty == null) { + return null; + } + + // 执行WebAuthn认证验证 + Map result = performAuthenticationVerification(responseData, passkey, serverProperty); + if (result == null) { + return null; + } + + // 标记挑战为已使用 + challenge.setStatus(1); + passkeyChallengeService.saveChallenge(challenge); + + _logger.debug("Authentication verification completed successfully"); + return result; + + } catch (VerificationException e) { + _logger.error("WebAuthn validation failed", e); + return null; + } catch (Exception e) { + _logger.error("Error verifying authentication response", e); + return null; + } + } + + /** + * 认证响应数据结构 + */ + private static class AuthenticationResponseData { + String credentialIdBase64; + String authenticatorDataBase64; + String clientDataJSONBase64; + String signatureBase64; + String userHandleBase64; + byte[] clientDataJSON; + } + + /** + * 解析认证响应 + */ + private AuthenticationResponseData parseAuthenticationResponse(Map authenticationResponse) { + String credentialIdBase64 = (String) authenticationResponse.get("credentialId"); + String authenticatorDataBase64 = (String) authenticationResponse.get("authenticatorData"); + String clientDataJSONBase64 = (String) authenticationResponse.get("clientDataJSON"); + String signatureBase64 = (String) authenticationResponse.get("signature"); + String userHandleBase64 = (String) authenticationResponse.get("userHandle"); + + if (credentialIdBase64 == null || authenticatorDataBase64 == null || + clientDataJSONBase64 == null || signatureBase64 == null) { + return null; + } + + _logger.info("=== AUTHENTICATION CREDENTIAL ID DEBUG ==="); + _logger.info("Received credentialIdBase64 from client: {}", credentialIdBase64); + _logger.info("CredentialIdBase64 length: {}", credentialIdBase64.length()); + _logger.info("=== END AUTHENTICATION CREDENTIAL ID DEBUG ==="); + + AuthenticationResponseData data = new AuthenticationResponseData(); + data.credentialIdBase64 = credentialIdBase64; + data.authenticatorDataBase64 = authenticatorDataBase64; + data.clientDataJSONBase64 = clientDataJSONBase64; + data.signatureBase64 = signatureBase64; + data.userHandleBase64 = userHandleBase64; + data.clientDataJSON = Base64.decodeBase64(clientDataJSONBase64); + + return data; + } + + /** + * 验证挑战与用户匹配 + */ + private boolean validateChallengeUserMatch(PasskeyChallenge challenge, UserPasskey passkey) { + if (challenge.getUserId() != null && !challenge.getUserId().equals(passkey.getUserId())) { + _logger.warn("Challenge user mismatch: expected {}, found {}", challenge.getUserId(), passkey.getUserId()); + return false; + } + return true; + } + + /** + * 执行认证验证 + */ + private Map performAuthenticationVerification(AuthenticationResponseData responseData, + UserPasskey passkey, ServerProperty serverProperty) { + try { + // 解码数据 + byte[] credentialId = Base64.decodeBase64(responseData.credentialIdBase64); + byte[] authenticatorData = Base64.decodeBase64(responseData.authenticatorDataBase64); + byte[] signature = Base64.decodeBase64(responseData.signatureBase64); + byte[] userHandle = responseData.userHandleBase64 != null ? Base64.decodeBase64(responseData.userHandleBase64) : null; + + // 从存储的凭据中重建CredentialRecord + CredentialRecord credentialRecord = buildCredentialRecord(passkey, credentialId); + if (credentialRecord == null) { + _logger.error("Failed to build credential record"); + return null; + } + + // 创建认证参数 + AuthenticationParameters authenticationParameters = new AuthenticationParameters( + serverProperty, + credentialRecord, + false, // userVerificationRequired + true // userPresenceRequired + ); + + // 更新认证参数 + authenticationParameters = new AuthenticationParameters( + serverProperty, + credentialRecord, + Arrays.asList(credentialId), + false, // userVerificationRequired + true // userPresenceRequired + ); + + // 解析认证数据 + String authenticationResponseJSON = objectConverter.getJsonConverter().writeValueAsString( + Map.of( + "id", responseData.credentialIdBase64, + "rawId", responseData.credentialIdBase64, + "response", Map.of( + "authenticatorData", responseData.authenticatorDataBase64, + "clientDataJSON", responseData.clientDataJSONBase64, + "signature", responseData.signatureBase64, + "userHandle", responseData.userHandleBase64 != null ? responseData.userHandleBase64 : "" + ), + "type", CREDENTIAL_TYPE_PUBLIC_KEY + ) + ); + + // 使用 WebAuthnManager 解析和验证认证 + AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponseJSON(authenticationResponseJSON); + webAuthnManager.verify(authenticationData, authenticationParameters); + + // 验证成功,更新凭据信息 + passkey.setLastUsedDate(new Date()); + passkey.setSignatureCount(authenticationData.getAuthenticatorData().getSignCount()); + userPasskeyService.saveOrUpdatePasskey(passkey); + + // 返回认证结果 + return Map.of( + "success", true, + "userId", passkey.getUserId(), + "credentialId", passkey.getCredentialId(), + "displayName", passkey.getDisplayName() != null ? passkey.getDisplayName() : "Unknown Device" + ); + + } catch (Exception e) { + _logger.error("Authentication verification failed: {}", e.getMessage(), e); + return null; + } + } + + /** + * 构建CredentialRecord + */ + private CredentialRecord buildCredentialRecord(UserPasskey passkey, byte[] credentialId) { + try { + byte[] publicKeyBytes = Base64.decodeBase64(passkey.getPublicKey()); + COSEKey coseKey = objectConverter.getCborConverter().readValue(publicKeyBytes, COSEKey.class); + + AttestedCredentialData attestedCredentialData = new AttestedCredentialData( + passkey.getAaguid() != null ? new AAGUID(Base64.decodeBase64(passkey.getAaguid())) : AAGUID.NULL, + credentialId, + coseKey + ); + + return new CredentialRecordImpl( + null, // attestationStatement + false, // uvInitialized + false, // backupEligible + false, // backupState + passkey.getSignatureCount(), // counter + attestedCredentialData, // attestedCredentialData + null, // authenticatorExtensions + null, // clientData + null, // clientExtensions + null // transports + ); + + } catch (Exception e) { + _logger.error("Failed to build CredentialRecord: {}", e.getMessage(), e); + return null; + } + } + + @Override + public List getUserPasskeys(String userId) { + return userPasskeyService.findByUserId(userId); + } + + @Override + public boolean deletePasskey(String userId, String credentialId) { + UserPasskey passkey = userPasskeyService.findByCredentialId(credentialId); + if (passkey != null && userId.equals(passkey.getUserId())) { + return userPasskeyService.deletePasskey(userId, credentialId); + } + return false; + } + + @Override + public boolean hasAnyRegisteredPasskeys() { + try { + // 通过查询所有有效用户的 Passkey 数量来判断系统中是否有注册的 Passkey + // 使用更高效的查询方法,只查询有效的 Passkey (status = 1) + List allPasskeys = userPasskeyService.findAll(); + if (allPasskeys == null || allPasskeys.isEmpty()) { + return false; + } + + // 检查是否有任何有效的 Passkey (status = 1) + for (UserPasskey passkey : allPasskeys) { + if (passkey.getStatus() != null && passkey.getStatus() == 1) { + return true; + } + } + + return false; + } catch (Exception e) { + _logger.error("Error checking for any registered passkeys", e); + return false; + } + } + + @Override + public boolean savePasskey(UserPasskey userPasskey) { + try { + if (userPasskey == null || userPasskey.getId() == null) { + _logger.warn("Cannot save null passkey or passkey with null ID"); + return false; + } + + _logger.debug("Saving passkey: ID={}, userId={}, credentialId={}", + userPasskey.getId(), userPasskey.getUserId(), userPasskey.getCredentialId()); + + userPasskeyService.saveOrUpdatePasskey(userPasskey); + + _logger.debug("Passkey saved successfully"); + return true; + + } catch (Exception e) { + _logger.error("Error saving passkey: {}", e.getMessage(), e); + return false; + } + } + + + + @Override + public UserPasskey getPasskeyByCredentialId(String credentialId) { + _logger.debug("Looking for passkey with credentialId: {}", credentialId); + + UserPasskey result = userPasskeyService.findByCredentialId(credentialId); + + if (result != null) { + _logger.debug("Found passkey for user: {}", result.getUserId()); + return result; + } + + _logger.debug("No passkey found for credentialId: {}", credentialId); + return null; + } + + @Override + public boolean updateSignatureCount(String credentialId, Long signatureCount) { + UserPasskey passkey = userPasskeyService.findByCredentialId(credentialId); + if (passkey != null) { + passkey.setSignatureCount(signatureCount); + userPasskeyService.saveOrUpdatePasskey(passkey); + return true; + } + return false; + } + + @Override + public boolean saveChallenge(PasskeyChallenge challenge) { + try { + passkeyChallengeService.saveChallenge(challenge); + return true; + } catch (Exception e) { + _logger.error("Error saving challenge", e); + return false; + } + } + + @Override + public PasskeyChallenge getChallenge(String challengeId) { + return passkeyChallengeService.findByChallengeId(challengeId); + } + + @Override + public int cleanExpiredChallenges() { + return passkeyChallengeService.cleanExpiredChallenges(); + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/util/PasskeyUtils.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/util/PasskeyUtils.java new file mode 100644 index 000000000..241ef0a38 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/util/PasskeyUtils.java @@ -0,0 +1,237 @@ +/** + * Copyright [2024] [MaxKey of copyright http://www.maxkey.top] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dromara.maxkey.passkey.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.PublicKeyCredentialParameters; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import org.apache.commons.codec.binary.Base64; +import org.dromara.maxkey.entity.passkey.PasskeyChallenge; +import org.dromara.maxkey.entity.passkey.UserPasskey; +import org.dromara.maxkey.passkey.config.PasskeyProperties; +import org.dromara.maxkey.util.IdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; + +/** + * Passkey工具类 + * 提供通用的验证和构建方法 + */ +public class PasskeyUtils { + + private static final Logger logger = LoggerFactory.getLogger(PasskeyUtils.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final SecureRandom secureRandom = new SecureRandom(); + private static final IdGenerator idGenerator = new IdGenerator(); + + /** + * 从clientDataJSON中解析并验证origin + */ + public static String parseAndValidateOrigin(String clientDataJSON, String expectedOrigin) { + try { + JsonNode clientData = objectMapper.readTree(clientDataJSON); + String origin = clientData.get("origin").asText(); + + if (!expectedOrigin.equals(origin)) { + logger.warn("Origin mismatch. Expected: {}, Actual: {}", expectedOrigin, origin); + throw new IllegalArgumentException("Origin validation failed"); + } + + return origin; + } catch (Exception e) { + logger.error("Failed to parse or validate origin from clientDataJSON", e); + throw new RuntimeException("Origin validation failed", e); + } + } + + /** + * 创建ServerProperty对象 + */ + public static ServerProperty createServerProperty(String origin, String rpId, byte[] challenge) { + return new ServerProperty( + Origin.create(origin), + rpId, + new DefaultChallenge(challenge), + null + ); + } + + /** + * Base64解码 + */ + public static byte[] base64Decode(String encoded) { + try { + return Base64.decodeBase64(encoded); + } catch (Exception e) { + logger.error("Failed to decode base64 string: {}", encoded, e); + throw new IllegalArgumentException("Invalid base64 encoding", e); + } + } + + /** + * Base64编码 + */ + public static String base64Encode(byte[] data) { + return Base64.encodeBase64URLSafeString(data); + } + + /** + * 验证字符串是否为空 + */ + public static void validateNotEmpty(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(fieldName + " cannot be null or empty"); + } + } + + /** + * 验证对象是否为空 + */ + public static void validateNotNull(Object value, String fieldName) { + if (value == null) { + throw new IllegalArgumentException(fieldName + " cannot be null"); + } + } + + /** + * 生成挑战 + */ + public static PasskeyChallenge generateChallenge(String userId, String challengeType, int challengeLength) { + byte[] challenge = new byte[challengeLength]; + secureRandom.nextBytes(challenge); + String challengeBase64 = Base64.encodeBase64URLSafeString(challenge); + + String challengeId = idGenerator.generate(); + PasskeyChallenge passkeyChallenge = new PasskeyChallenge(challengeId, challengeBase64, challengeType); + passkeyChallenge.setUserId(userId); + + return passkeyChallenge; + } + + /** + * 构建RP信息 + */ + public static Map buildRelyingPartyInfo(PasskeyProperties.RelyingParty relyingParty) { + Map rp = new HashMap<>(); + rp.put("name", relyingParty.getName()); + rp.put("id", relyingParty.getId()); + if (relyingParty.getIcon() != null) { + rp.put("icon", relyingParty.getIcon()); + } + return rp; + } + + /** + * 构建用户信息 + */ + public static Map buildUserInfo(String userId, String username, String displayName) { + Map user = new HashMap<>(); + user.put("id", Base64.encodeBase64URLSafeString(userId.getBytes())); + user.put("name", username); + user.put("displayName", displayName); + return user; + } + + /** + * 构建公钥凭据参数 + */ + public static List> buildPublicKeyCredentialParams(List parameters) { + List> pubKeyCredParams = new ArrayList<>(); + for (PublicKeyCredentialParameters param : parameters) { + Map paramMap = new HashMap<>(); + paramMap.put("type", param.getType().getValue()); + paramMap.put("alg", param.getAlg().getValue()); + pubKeyCredParams.add(paramMap); + } + return pubKeyCredParams; + } + + /** + * 构建认证器选择标准 + */ + public static Map buildAuthenticatorSelection(PasskeyProperties.Authenticator authenticator) { + Map authenticatorSelection = new HashMap<>(); + authenticatorSelection.put("authenticatorAttachment", authenticator.getAttachment()); + authenticatorSelection.put("userVerification", authenticator.getUserVerification()); + authenticatorSelection.put("requireResidentKey", authenticator.isRequireResidentKey()); + return authenticatorSelection; + } + + /** + * 构建凭据列表 + */ + public static List> buildCredentialList(List passkeys) { + List> credentialList = new ArrayList<>(); + + for (UserPasskey passkey : passkeys) { + Map credentialMap = new HashMap<>(); + credentialMap.put("type", "public-key"); + credentialMap.put("id", passkey.getCredentialId()); + credentialList.add(credentialMap); + } + + return credentialList; + } + + /** + * 创建ServerProperty对象(重载方法) + */ + public static ServerProperty createServerProperty(byte[] clientDataJSON, String challengeBase64, + PasskeyProperties.RelyingParty relyingParty, + ObjectConverter objectConverter) { + try { + String clientDataJSONString = new String(clientDataJSON, StandardCharsets.UTF_8); + logger.debug("ClientDataJSON string: {}", clientDataJSONString); + + Map clientData = objectConverter.getJsonConverter().readValue(clientDataJSONString, Map.class); + String actualOrigin = (String) clientData.get("origin"); + + logger.debug("Actual origin from clientData: {}", actualOrigin); + + if (actualOrigin == null || actualOrigin.trim().isEmpty()) { + logger.error("Origin is null or empty in clientDataJSON"); + return null; + } + + // 验证origin + List allowedOrigins = relyingParty.getAllowedOrigins(); + if (!allowedOrigins.contains(actualOrigin)) { + logger.warn("Origin {} not in allowed origins: {}", actualOrigin, allowedOrigins); + return null; + } + + Origin origin = new Origin(actualOrigin); + String rpId = relyingParty.getId(); + Challenge challengeObj = new DefaultChallenge(Base64.decodeBase64(challengeBase64)); + + return new ServerProperty(origin, rpId, challengeObj, null); + + } catch (Exception e) { + logger.error("Failed to create ServerProperty: {}", e.getMessage(), e); + return null; + } + } +} \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/resources/META-INF/spring.factories b/maxkey-starter/maxkey-starter-passkey/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..9145698cd --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/resources/META-INF/spring.factories @@ -0,0 +1,4 @@ +# Spring Boot 2.x 兼容性配置 +# 自动配置类 +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.dromara.maxkey.passkey.autoconfigure.PasskeyAutoConfiguration \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/maxkey-starter/maxkey-starter-passkey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..728b10138 --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.maxkey.passkey.autoconfigure.PasskeyAutoConfiguration \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/resources/application-passkey.yml b/maxkey-starter/maxkey-starter-passkey/src/main/resources/application-passkey.yml new file mode 100644 index 000000000..d6a430c1b --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/resources/application-passkey.yml @@ -0,0 +1,165 @@ +# MaxKey Passkey 模块配置示例 +# 将此配置添加到主应用的 application.yml 中 + +maxkey: + passkey: + # 是否启用 Passkey 功能 + enabled: true + + # RP (Relying Party) 配置 + relying-party: + # RP 名称,显示给用户 + name: "MaxKey" + # RP ID,通常是域名(生产环境必须配置为实际域名) + id: "localhost" + # RP 图标 URL(可选) + icon: "/static/images/maxkey-logo.png" + + # 认证器配置 + authenticator: + # 认证器附件偏好:platform(平台认证器), cross-platform(跨平台认证器), null(无偏好) + attachment: "platform" + # 用户验证要求:required(必需), preferred(首选), discouraged(不鼓励) + user-verification: "required" + # 证明偏好:none(无), indirect(间接), direct(直接) + attestation: "none" + # 是否要求驻留密钥(可发现凭据) + require-resident-key: false + + # 挑战配置 + challenge: + # 挑战长度(字节) + length: 32 + # 挑战过期时间(分钟) + expire-minutes: 5 + # 操作超时时间(毫秒) + timeout-ms: 60000 + # 是否自动清理过期挑战 + auto-cleanup: true + # 清理间隔(小时) + cleanup-interval-hours: 1 + + # 用户限制配置 + user-limits: + # 每个用户最大 Passkey 数量 + max-passkeys-per-user: 5 + # 是否允许重复注册相同设备 + allow-duplicate-devices: false + + # 会话配置 + session: + # 认证会话过期时间(分钟) + auth-session-expire-minutes: 30 + # 是否启用会话管理 + enabled: true + +# 日志配置(可选) +logging: + level: + # Passkey 模块日志级别 + org.dromara.maxkey.passkey: INFO + # WebAuthn 相关日志 + com.webauthn4j: WARN + +# 数据源配置(如果使用独立数据源) +# spring: +# datasource: +# passkey: +# url: jdbc:mysql://localhost:3306/maxkey_passkey +# username: maxkey +# password: maxkey +# driver-class-name: com.mysql.cj.jdbc.Driver + +# 缓存配置(可选,用于挑战缓存) +# spring: +# cache: +# type: redis +# redis: +# host: localhost +# port: 6379 +# database: 1 +# timeout: 2000ms +# lettuce: +# pool: +# max-active: 8 +# max-idle: 8 +# min-idle: 0 + +# 安全配置 +security: + # CORS 配置(如果需要跨域支持) + cors: + allowed-origins: + - "https://your-domain.com" + - "http://localhost:3000" # 开发环境 + allowed-methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed-headers: + - "*" + allow-credentials: true + +# 监控配置(可选) +management: + endpoints: + web: + exposure: + include: + - health + - info + - metrics + - passkey # 自定义 Passkey 监控端点 + endpoint: + health: + show-details: when-authorized + metrics: + tags: + application: maxkey-passkey + +# 生产环境配置示例 +--- +spring: + profiles: production + +maxkey: + passkey: + relying-party: + # 生产环境必须使用实际域名 + id: "auth.yourcompany.com" + name: "Your Company SSO" + authenticator: + # 生产环境建议使用更严格的验证 + user-verification: "required" + attestation: "indirect" + challenge: + # 生产环境可以使用更短的过期时间 + expire-minutes: 3 + timeout-ms: 30000 + session: + # 生产环境可以使用更短的会话时间 + auth-session-expire-minutes: 15 + +# 开发环境配置示例 +--- +spring: + profiles: development + +maxkey: + passkey: + relying-party: + id: "localhost" + authenticator: + # 开发环境可以放宽验证要求 + user-verification: "preferred" + challenge: + # 开发环境使用更长的过期时间便于调试 + expire-minutes: 10 + timeout-ms: 120000 + +logging: + level: + org.dromara.maxkey.passkey: DEBUG + root: INFO \ No newline at end of file diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/resources/sql/passkey-schema.sql b/maxkey-starter/maxkey-starter-passkey/src/main/resources/sql/passkey-schema.sql new file mode 100644 index 000000000..c7440941c --- /dev/null +++ b/maxkey-starter/maxkey-starter-passkey/src/main/resources/sql/passkey-schema.sql @@ -0,0 +1,107 @@ +-- Passkey模块数据库表结构 +-- 用于存储用户Passkey凭据和认证挑战信息 + +-- 用户Passkey凭据表 +CREATE TABLE mxk_user_passkeys ( + ID VARCHAR(40) NOT NULL, + USER_ID VARCHAR(40) NOT NULL COMMENT '用户ID', + CREDENTIAL_ID VARCHAR(1024) NOT NULL COMMENT 'WebAuthn凭据ID', + PUBLIC_KEY TEXT NOT NULL COMMENT '公钥信息', + SIGNATURE_COUNT BIGINT DEFAULT 0 COMMENT '签名计数器', + AAGUID VARCHAR(100) COMMENT '认证器AAGUID', + DISPLAY_NAME VARCHAR(200) COMMENT '显示名称', + DEVICE_TYPE VARCHAR(50) DEFAULT 'platform' COMMENT '设备类型:platform/cross-platform', + CREATED_DATE DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + LAST_USED_DATE DATETIME COMMENT '最后使用时间', + STATUS INT DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + INST_ID VARCHAR(40) DEFAULT '1' COMMENT '机构ID', + PRIMARY KEY (ID), + UNIQUE KEY UK_USER_CREDENTIAL (USER_ID, CREDENTIAL_ID), + KEY IDX_USER_ID (USER_ID), + KEY IDX_CREDENTIAL_ID (CREDENTIAL_ID(255)), + KEY IDX_STATUS (STATUS), + KEY IDX_INST_ID (INST_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户Passkey凭据表'; + +-- 添加索引优化查询性能 +CREATE INDEX IDX_USER_STATUS ON mxk_user_passkeys(USER_ID, STATUS); +CREATE INDEX IDX_CREATED_DATE ON mxk_user_passkeys(CREATED_DATE); +CREATE INDEX IDX_LAST_USED ON mxk_user_passkeys(LAST_USED_DATE); + +-- Passkey认证挑战表 +CREATE TABLE mxk_passkey_challenges ( + ID VARCHAR(40) NOT NULL, + USER_ID VARCHAR(40) COMMENT '用户ID(可为空,支持无用户名登录)', + CHALLENGE VARCHAR(1024) NOT NULL COMMENT '挑战字符串', + CHALLENGE_TYPE VARCHAR(20) NOT NULL COMMENT '挑战类型:REGISTRATION/AUTHENTICATION', + SESSION_ID VARCHAR(100) COMMENT '会话ID', + CREATED_DATE DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + EXPIRE_DATE DATETIME NOT NULL COMMENT '过期时间', + STATUS INT DEFAULT 0 COMMENT '状态:0-未使用,1-已使用', + INST_ID VARCHAR(40) DEFAULT '1' COMMENT '机构ID', + PRIMARY KEY (ID), + KEY IDX_USER_ID (USER_ID), + KEY IDX_CHALLENGE_TYPE (CHALLENGE_TYPE), + KEY IDX_SESSION_ID (SESSION_ID), + KEY IDX_EXPIRE_DATE (EXPIRE_DATE), + KEY IDX_STATUS (STATUS), + KEY IDX_INST_ID (INST_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Passkey认证挑战表'; + +-- 添加复合索引优化查询 +CREATE INDEX IDX_CHALLENGE_STATUS ON mxk_passkey_challenges(CHALLENGE_TYPE, STATUS); +CREATE INDEX IDX_USER_TYPE ON mxk_passkey_challenges(USER_ID, CHALLENGE_TYPE); +CREATE INDEX IDX_EXPIRE_STATUS ON mxk_passkey_challenges(EXPIRE_DATE, STATUS); + +-- 为现有用户表添加Passkey相关字段(可选方案) +-- 如果选择在现有mxk_userinfo表中添加字段,可以使用以下SQL: +/* +ALTER TABLE mxk_userinfo ADD COLUMN PASSKEY_ENABLED INT DEFAULT 0 COMMENT 'Passkey功能是否启用:0-禁用,1-启用'; +ALTER TABLE mxk_userinfo ADD COLUMN PASSKEY_COUNT INT DEFAULT 0 COMMENT '用户Passkey数量'; +ALTER TABLE mxk_userinfo ADD COLUMN LAST_PASSKEY_LOGIN DATETIME COMMENT '最后一次Passkey登录时间'; + +-- 添加索引 +CREATE INDEX IDX_PASSKEY_ENABLED ON mxk_userinfo(PASSKEY_ENABLED); +CREATE INDEX IDX_LAST_PASSKEY_LOGIN ON mxk_userinfo(LAST_PASSKEY_LOGIN); +*/ + +-- 创建清理过期挑战的存储过程 +DELIMITER // +CREATE PROCEDURE CleanExpiredPasskeyChallenges() +BEGIN + DECLARE affected_rows INT DEFAULT 0; + + -- 删除过期的挑战记录 + DELETE FROM mxk_passkey_challenges + WHERE EXPIRE_DATE < NOW(); + + -- 获取影响的行数 + SET affected_rows = ROW_COUNT(); + + -- 记录清理结果 + SELECT CONCAT('Cleaned ', affected_rows, ' expired passkey challenges') AS result; +END // +DELIMITER ; + +-- 创建定时清理事件(可选) +/* +CREATE EVENT IF NOT EXISTS CleanPasskeyChallengesEvent +ON SCHEDULE EVERY 1 HOUR +DO + CALL CleanExpiredPasskeyChallenges(); +*/ + +-- 插入一些示例数据(仅用于测试) +/* +INSERT INTO mxk_user_passkeys ( + ID, USER_ID, CREDENTIAL_ID, PUBLIC_KEY, DISPLAY_NAME, DEVICE_TYPE, INST_ID +) VALUES ( + 'test-passkey-001', + 'admin', + 'test-credential-id-001', + 'test-public-key-data', + 'Test Passkey Device', + 'platform', + '1' +); +*/ \ No newline at end of file diff --git a/maxkey-webs/maxkey-web-maxkey/build.gradle b/maxkey-webs/maxkey-web-maxkey/build.gradle index 02bfa754e..5bc874cc2 100644 --- a/maxkey-webs/maxkey-web-maxkey/build.gradle +++ b/maxkey-webs/maxkey-web-maxkey/build.gradle @@ -51,6 +51,7 @@ dependencies { implementation project(":maxkey-starter:maxkey-starter-sms") implementation project(":maxkey-starter:maxkey-starter-social") implementation project(":maxkey-starter:maxkey-starter-web") + implementation project(":maxkey-starter:maxkey-starter-passkey") implementation project(":maxkey-authentications:maxkey-authentication-core") implementation project(":maxkey-authentications:maxkey-authentication-provider") diff --git a/settings.gradle b/settings.gradle index 806894aa2..47bab2168 100644 --- a/settings.gradle +++ b/settings.gradle @@ -37,6 +37,7 @@ include ('maxkey-starter:maxkey-starter-otp') include ('maxkey-starter:maxkey-starter-sms') include ('maxkey-starter:maxkey-starter-social') include ('maxkey-starter:maxkey-starter-web') +include ('maxkey-starter:maxkey-starter-passkey') //authentications include ('maxkey-authentications:maxkey-authentication-core')