mirror of
https://gitee.com/dromara/MaxKey.git
synced 2025-12-06 17:08:29 +08:00
Compare commits
5 Commits
9fdbefc89e
...
585a24d466
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
585a24d466 | ||
|
|
1317918fee | ||
|
|
0ea7b76113 | ||
|
|
c0d02e29e1 | ||
|
|
7a064e495a |
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@ -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<PasskeyChallenge> {
|
||||
|
||||
/**
|
||||
* 根据挑战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);
|
||||
}
|
||||
@ -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<UserPasskey> {
|
||||
|
||||
/**
|
||||
* 根据用户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<UserPasskey> 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);
|
||||
}
|
||||
@ -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<PasskeyChallenge> {
|
||||
|
||||
/**
|
||||
* 根据挑战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);
|
||||
}
|
||||
@ -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<UserPasskey> {
|
||||
|
||||
/**
|
||||
* 根据用户ID查询所有Passkey凭据
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return Passkey凭据列表
|
||||
*/
|
||||
List<UserPasskey> 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<String, Object> getPasskeyStats(String userId);
|
||||
}
|
||||
@ -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<PasskeyChallengeMapper, PasskeyChallenge> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<UserPasskeyMapper, UserPasskey> implements UserPasskeyService {
|
||||
|
||||
private static final Logger _logger = LoggerFactory.getLogger(UserPasskeyServiceImpl.class);
|
||||
|
||||
@Override
|
||||
public List<UserPasskey> 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<String, Object> getPasskeyStats(String userId) {
|
||||
_logger.debug("Getting passkey stats for user: {}", userId);
|
||||
try {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 获取用户的Passkey数量
|
||||
int count = countByUserId(userId);
|
||||
stats.put("count", count);
|
||||
|
||||
// 获取用户的Passkey列表(用于显示设备信息)
|
||||
List<UserPasskey> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
maxkey-starter/maxkey-starter-passkey/REFACTORING_SUMMARY.md
Normal file
106
maxkey-starter/maxkey-starter-passkey/REFACTORING_SUMMARY.md
Normal file
@ -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. 考虑添加更多的参数验证逻辑
|
||||
15
maxkey-starter/maxkey-starter-passkey/build.gradle
Normal file
15
maxkey-starter/maxkey-starter-passkey/build.gradle
Normal file
@ -0,0 +1,15 @@
|
||||
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-core")
|
||||
implementation project(":maxkey-entity")
|
||||
implementation project(":maxkey-persistence")
|
||||
implementation project(":maxkey-authentications:maxkey-authentication-core")
|
||||
implementation project(":maxkey-authentications:maxkey-authentication-provider")
|
||||
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<String> getAllowedOrigins() {
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
public void setAllowedOrigins(java.util.List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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> 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<GrantedAuthority> grantedAdministratorsAuthoritys = new ArrayList<GrantedAuthority>();
|
||||
|
||||
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<GrantedAuthority> 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<String, Object> 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<String, Object> 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<String, Object> request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse httpResponse) {
|
||||
|
||||
_logger.debug("Finish Passkey authentication request received");
|
||||
|
||||
try {
|
||||
// 验证认证响应
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<UserPasskey> 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<String, Object> 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<String, Object> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> beginRegistration(String userId, String username, String displayName) {
|
||||
_logger.debug("Beginning Passkey registration for user: {}", userId);
|
||||
|
||||
try {
|
||||
// 检查用户是否已有Passkey
|
||||
List<UserPasskey> 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<String, Object> 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<String, Object> beginAuthentication(String userId) {
|
||||
_logger.debug("Beginning Passkey authentication for user: {}", userId);
|
||||
|
||||
try {
|
||||
// 如果指定了用户ID,检查用户是否有可用的Passkey
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
List<UserPasskey> 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<String, Object> finishAuthentication(Map<String, Object> authenticationResponse) {
|
||||
_logger.debug("Finishing Passkey authentication");
|
||||
|
||||
try {
|
||||
Map<String, Object> 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<UserPasskey> 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<String, Object> getPasskeyStats(String userId) {
|
||||
List<UserPasskey> passkeys = passkeyService.getUserPasskeys(userId);
|
||||
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> generateRegistrationOptions(String userId, String username, String displayName);
|
||||
|
||||
/**
|
||||
* 验证注册响应
|
||||
* @param userId 用户ID
|
||||
* @param registrationResponse 注册响应JSON
|
||||
* @return 验证成功时返回新创建的UserPasskey对象,失败时返回null
|
||||
*/
|
||||
UserPasskey verifyRegistrationResponse(String userId, Map<String, Object> registrationResponse);
|
||||
|
||||
/**
|
||||
* 生成认证选项
|
||||
* @param userId 用户ID(可选,为空时返回所有可用凭据)
|
||||
* @return 认证选项JSON
|
||||
*/
|
||||
Map<String, Object> generateAuthenticationOptions(String userId);
|
||||
|
||||
/**
|
||||
* 验证认证响应
|
||||
* @param authenticationResponse 认证响应JSON
|
||||
* @return 验证结果,包含用户ID
|
||||
*/
|
||||
Map<String, Object> verifyAuthenticationResponse(Map<String, Object> authenticationResponse);
|
||||
|
||||
/**
|
||||
* 获取用户的所有Passkey凭据
|
||||
* @param userId 用户ID
|
||||
* @return Passkey凭据列表
|
||||
*/
|
||||
List<UserPasskey> 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();
|
||||
}
|
||||
@ -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> publicKeyCredentialParameters;
|
||||
|
||||
@Autowired
|
||||
private UserPasskeyService userPasskeyService;
|
||||
|
||||
@Autowired
|
||||
private PasskeyChallengeService passkeyChallengeService;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final IdGenerator idGenerator = new IdGenerator();
|
||||
|
||||
@Override
|
||||
public Map<String, Object> 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<String, Object> 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<String, Object> buildRegistrationOptions(String userId, String username, String displayName,
|
||||
String challengeId, String challengeBase64) {
|
||||
Map<String, Object> 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<Map<String, Object>> excludeCredentials = buildExcludeCredentials(userId);
|
||||
if (!excludeCredentials.isEmpty()) {
|
||||
options.put("excludeCredentials", excludeCredentials);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建RP信息
|
||||
*/
|
||||
private Map<String, Object> buildRelyingPartyInfo() {
|
||||
return PasskeyUtils.buildRelyingPartyInfo(passkeyProperties.getRelyingParty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户信息
|
||||
*/
|
||||
private Map<String, Object> buildUserInfo(String userId, String username, String displayName) {
|
||||
return PasskeyUtils.buildUserInfo(userId, username, displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建公钥凭据参数
|
||||
*/
|
||||
private List<Map<String, Object>> buildPublicKeyCredentialParams() {
|
||||
return PasskeyUtils.buildPublicKeyCredentialParams(publicKeyCredentialParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建认证器选择标准
|
||||
*/
|
||||
private Map<String, Object> buildAuthenticatorSelection() {
|
||||
return PasskeyUtils.buildAuthenticatorSelection(passkeyProperties.getAuthenticator());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建排除凭据列表
|
||||
*/
|
||||
private List<Map<String, Object>> buildExcludeCredentials(String userId) {
|
||||
List<UserPasskey> existingPasskeys = userPasskeyService.findByUserId(userId);
|
||||
return PasskeyUtils.buildCredentialList(existingPasskeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserPasskey verifyRegistrationResponse(String userId, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> verifyAuthenticationResponse(Map<String, Object> 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<UserPasskey> 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<String, Object> 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<String, Object> 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<String, Object> 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<UserPasskey> 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<UserPasskey> 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();
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> buildRelyingPartyInfo(PasskeyProperties.RelyingParty relyingParty) {
|
||||
Map<String, Object> 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<String, Object> buildUserInfo(String userId, String username, String displayName) {
|
||||
Map<String, Object> user = new HashMap<>();
|
||||
user.put("id", Base64.encodeBase64URLSafeString(userId.getBytes()));
|
||||
user.put("name", username);
|
||||
user.put("displayName", displayName);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建公钥凭据参数
|
||||
*/
|
||||
public static List<Map<String, Object>> buildPublicKeyCredentialParams(List<PublicKeyCredentialParameters> parameters) {
|
||||
List<Map<String, Object>> pubKeyCredParams = new ArrayList<>();
|
||||
for (PublicKeyCredentialParameters param : parameters) {
|
||||
Map<String, Object> paramMap = new HashMap<>();
|
||||
paramMap.put("type", param.getType().getValue());
|
||||
paramMap.put("alg", param.getAlg().getValue());
|
||||
pubKeyCredParams.add(paramMap);
|
||||
}
|
||||
return pubKeyCredParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建认证器选择标准
|
||||
*/
|
||||
public static Map<String, Object> buildAuthenticatorSelection(PasskeyProperties.Authenticator authenticator) {
|
||||
Map<String, Object> authenticatorSelection = new HashMap<>();
|
||||
authenticatorSelection.put("authenticatorAttachment", authenticator.getAttachment());
|
||||
authenticatorSelection.put("userVerification", authenticator.getUserVerification());
|
||||
authenticatorSelection.put("requireResidentKey", authenticator.isRequireResidentKey());
|
||||
return authenticatorSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建凭据列表
|
||||
*/
|
||||
public static List<Map<String, Object>> buildCredentialList(List<UserPasskey> passkeys) {
|
||||
List<Map<String, Object>> credentialList = new ArrayList<>();
|
||||
|
||||
for (UserPasskey passkey : passkeys) {
|
||||
Map<String, Object> 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<String, Object> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
org.dromara.maxkey.passkey.autoconfigure.PasskeyAutoConfiguration
|
||||
@ -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")
|
||||
|
||||
@ -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')
|
||||
|
||||
52
sql/v4.1.9/passkey_tables.sql
Normal file
52
sql/v4.1.9/passkey_tables.sql
Normal file
@ -0,0 +1,52 @@
|
||||
-- MaxKey Passkey 模块数据库表创建脚本
|
||||
-- 创建用户 Passkey 表和挑战表
|
||||
|
||||
-- 用户 Passkey 表
|
||||
CREATE TABLE IF NOT EXISTS mxk_user_passkeys (
|
||||
id VARCHAR(40) NOT NULL COMMENT '主键ID',
|
||||
user_id VARCHAR(40) NOT NULL COMMENT '用户ID',
|
||||
credential_id VARCHAR(500) NOT NULL COMMENT '凭据ID(Base64编码)',
|
||||
public_key TEXT NOT NULL COMMENT '公钥数据(Base64编码)',
|
||||
display_name VARCHAR(100) COMMENT '显示名称',
|
||||
device_type VARCHAR(50) DEFAULT 'unknown' COMMENT '设备类型',
|
||||
signature_count BIGINT DEFAULT 0 COMMENT '签名计数器',
|
||||
created_date DATETIME NOT NULL COMMENT '创建时间',
|
||||
last_used_date DATETIME COMMENT '最后使用时间',
|
||||
aaguid VARCHAR(100) COMMENT 'AAGUID',
|
||||
inst_id VARCHAR(40) DEFAULT '1' COMMENT '机构ID',
|
||||
status INT DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_credential_id (credential_id),
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_inst_id (inst_id),
|
||||
KEY idx_created_date (created_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户Passkey凭据表';
|
||||
|
||||
-- Passkey 挑战表
|
||||
CREATE TABLE IF NOT EXISTS mxk_passkey_challenges (
|
||||
id VARCHAR(40) NOT NULL COMMENT '挑战ID',
|
||||
user_id VARCHAR(40) COMMENT '用户ID(认证时可为空)',
|
||||
challenge TEXT NOT NULL COMMENT '挑战数据(Base64编码)',
|
||||
challenge_type VARCHAR(20) NOT NULL COMMENT '挑战类型:REGISTRATION-注册,AUTHENTICATION-认证',
|
||||
created_date DATETIME NOT NULL COMMENT '创建时间',
|
||||
expires_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_expires_date (expires_date),
|
||||
KEY idx_inst_id (inst_id),
|
||||
KEY idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Passkey挑战表';
|
||||
|
||||
-- 创建索引以优化查询性能
|
||||
CREATE INDEX idx_user_passkeys_user_status ON mxk_user_passkeys(user_id, status);
|
||||
CREATE INDEX idx_passkey_challenges_expires_status ON mxk_passkey_challenges(expires_date, status);
|
||||
CREATE INDEX idx_passkey_challenges_user_type ON mxk_passkey_challenges(user_id, challenge_type);
|
||||
|
||||
-- 插入示例数据(可选)
|
||||
-- INSERT INTO mxk_user_passkeys (id, user_id, credential_id, public_key, display_name, device_type, signature_count, created_date, inst_id)
|
||||
-- VALUES ('test_passkey_1', 'test_user_1', 'test_credential_id_1', 'test_public_key_1', 'Test Device', 'platform', 0, NOW(), '1');
|
||||
|
||||
COMMIT;
|
||||
Loading…
x
Reference in New Issue
Block a user