Compare commits

...

3 Commits

Author SHA1 Message Date
MaxKeyTop
c02ae9bbc1
!66 删除多余文件,修复编译i问题
Merge pull request !66 from Spock12138/feature/passkey-optimization
2025-09-12 06:31:55 +00:00
Spock12138
0733ce2d17 fix: 修复PasskeyRegistrationEndpoint中@PathVariable注解缺少value属性的问题 2025-09-12 12:23:50 +08:00
Spock12138
33734f1387 feat: 实现 passkey 登录注册功能前端支持
- 添加 passkey 组件和相关路由配置
- 修复 build.gradle 添加 -parameters 编译参数
- 更新前端依赖和国际化配置
- 优化登录界面支持 passkey 认证
2025-09-11 22:55:06 +08:00
23 changed files with 979 additions and 129 deletions

View File

@ -17,6 +17,7 @@
package org.dromara.maxkey.persistence.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
@ -70,7 +71,7 @@ public interface PasskeyChallengeMapper extends IJpaMapper<PasskeyChallenge> {
@Result(column = "status", property = "status"),
@Result(column = "inst_id", property = "instId")
})
PasskeyChallenge findLatestByUserIdAndType(String userId, String challengeType);
PasskeyChallenge findLatestByUserIdAndType(@Param("userId") String userId, @Param("challengeType") String challengeType);
/**
* 删除指定挑战ID的记录
@ -106,7 +107,7 @@ public interface PasskeyChallengeMapper extends IJpaMapper<PasskeyChallenge> {
* @return 清理的记录数
*/
@Delete("DELETE FROM mxk_passkey_challenges WHERE user_id = #{userId} AND challenge_type = #{challengeType}")
int deleteByUserIdAndType(String userId, String challengeType);
int deleteByUserIdAndType(@Param("userId") String userId, @Param("challengeType") String challengeType);
/**
* 统计指定用户的挑战数量

View File

@ -19,6 +19,7 @@ package org.dromara.maxkey.persistence.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
@ -102,7 +103,7 @@ public interface UserPasskeyMapper extends IJpaMapper<UserPasskey> {
@Result(column = "status", property = "status"),
@Result(column = "inst_id", property = "instId")
})
UserPasskey findByUserIdAndCredentialId(String userId, String credentialId);
UserPasskey findByUserIdAndCredentialId(@Param("userId") String userId, @Param("credentialId") String credentialId);
/**
* 更新签名计数器
@ -112,7 +113,7 @@ public interface UserPasskeyMapper extends IJpaMapper<UserPasskey> {
* @return 更新的记录数
*/
@Update("UPDATE mxk_user_passkeys SET signature_count = #{signatureCount}, last_used_date = NOW() WHERE credential_id = #{credentialId}")
int updateSignatureCount(String credentialId, Long signatureCount);
int updateSignatureCount(@Param("credentialId") String credentialId, @Param("signatureCount") Long signatureCount);
/**
* 物理删除Passkey
@ -122,7 +123,7 @@ public interface UserPasskeyMapper extends IJpaMapper<UserPasskey> {
* @return 删除的记录数
*/
@Delete("DELETE FROM mxk_user_passkeys WHERE user_id = #{userId} AND credential_id = #{credentialId}")
int deleteByUserIdAndCredentialId(String userId, String credentialId);
int deleteByUserIdAndCredentialId(@Param("userId") String userId, @Param("credentialId") String credentialId);
/**
* 物理删除过期的Passkey记录

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,106 +0,0 @@
# 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. 考虑添加更多的参数验证逻辑

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.
@ -156,7 +156,7 @@ public class PasskeyRegistrationEndpoint {
*/
@GetMapping("/list/{userId}")
public ResponseEntity<?> getUserPasskeys(
@PathVariable String userId,
@PathVariable("userId") String userId,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
@ -195,8 +195,8 @@ public class PasskeyRegistrationEndpoint {
*/
@DeleteMapping("/delete/{userId}/{credentialId}")
public ResponseEntity<?> deletePasskey(
@PathVariable String userId,
@PathVariable String credentialId,
@PathVariable("userId") String userId,
@PathVariable("credentialId") String credentialId,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
@ -240,7 +240,7 @@ public class PasskeyRegistrationEndpoint {
*/
@GetMapping("/stats/{userId}")
public ResponseEntity<?> getPasskeyStats(
@PathVariable String userId,
@PathVariable("userId") String userId,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
@ -273,7 +273,7 @@ public class PasskeyRegistrationEndpoint {
*/
@GetMapping("/support/{userId}")
public ResponseEntity<?> checkPasskeySupport(
@PathVariable String userId,
@PathVariable("userId") String userId,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,5 +1,5 @@
/*
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -1,5 +1,5 @@
/**
* Copyright [2024] [MaxKey of copyright http://www.maxkey.top]
* 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.

View File

@ -27,8 +27,14 @@ import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
import { NzStepsModule } from 'ng-zorro-antd/steps';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzListModule } from 'ng-zorro-antd/list';
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
import { AccoutsComponent } from './accouts/accouts.component';
import { MfaComponent } from './mfa/mfa.component';
import { PasskeyComponent } from './passkey/passkey.component';
import { PasswordComponent } from './password/password.component';
import { ProfileComponent } from './profile/profile.component';
import { SocialsAssociateComponent } from './socials-associate/socials-associate.component';
@ -44,6 +50,10 @@ const routes: Routes = [
path: 'password',
component: PasswordComponent
},
{
path: 'passkey',
component: PasskeyComponent
},
{
path: 'socialsassociate',
component: SocialsAssociateComponent
@ -69,9 +79,10 @@ const COMPONENTS = [ProfileComponent];
PasswordComponent,
ProfileComponent,
AccoutsComponent,
MfaComponent
MfaComponent,
PasskeyComponent
],
imports: [SharedModule, CommonModule, RouterModule.forChild(routes)],
imports: [SharedModule, CommonModule, RouterModule.forChild(routes), NzEmptyModule, NzListModule, NzPopconfirmModule],
exports: [RouterModule]
})
export class ConfigModule {}

View File

@ -0,0 +1,58 @@
<nz-card nzTitle="Passkey 管理">
<div nz-row [nzGutter]="24">
<div nz-col [nzSpan]="24">
<div class="mb-md">
<p class="text-grey">Passkey 是一种更安全、更便捷的登录方式,使用您的设备生物识别或 PIN 码进行身份验证。</p>
</div>
<div class="mb-lg">
<button
nz-button
nzType="primary"
nzSize="large"
[nzLoading]="loading"
(click)="registerPasskey()">
<i nz-icon nzType="plus-circle" nzTheme="outline"></i>
注册新的 Passkey
</button>
</div>
<nz-divider nzText="已注册的 Passkey"></nz-divider>
<nz-table #basicTable [nzData]="passkeyList" [nzShowPagination]="false">
<thead>
<tr>
<th>凭证信息</th>
<th>签名统计</th>
<th>创建时间</th>
<th>最近访问时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of passkeyList; let i = index">
<td>
<div class="credential-info">
<div class="credential-id">{{ item.credentialId || item.id }}</div>
<div class="device-type">{{ item.deviceType === 'platform' ? '平台认证器' : '跨平台认证器' }}</div>
</div>
</td>
<td>{{ item.signatureCount || 0 }}</td>
<td>{{ item.createdDate | date:'yyyy-MM-dd HH:mm:ss' }}</td>
<td>{{ item.lastUsedDate | date:'yyyy-MM-dd HH:mm:ss' }}</td>
<td>
<button nz-button nzType="link" nzDanger nzSize="small" (click)="confirmDeletePasskey(item.credentialId || item.id)">
删除 passkey
</button>
</td>
</tr>
</tbody>
</nz-table>
</div>
</div>
</nz-card>

View File

@ -0,0 +1,112 @@
.text-grey {
color: #666;
line-height: 1.6;
}
.mb-md {
margin-bottom: 16px;
}
.mb-lg {
margin-bottom: 24px;
}
.mt-lg {
margin-top: 24px;
}
.py-lg {
padding: 24px 0;
}
.text-center {
text-align: center;
}
nz-list-item {
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
nz-list-item-meta-title h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
nz-list-item-meta-description p {
margin: 4px 0;
color: #666;
font-size: 12px;
}
.passkey-info {
p {
margin: 6px 0;
line-height: 1.4;
strong {
color: #333;
font-weight: 500;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 11px;
color: #d63384;
word-break: break-all;
}
}
}
nz-list-item-meta-title {
display: flex;
align-items: center;
gap: 8px;
h4 {
margin: 0;
flex: 1;
}
nz-tag {
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
}
}
// 表格样式
.credential-info {
.credential-id {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
color: #333;
margin-bottom: 4px;
word-break: break-all;
}
.device-type {
font-size: 12px;
color: #666;
}
}
nz-table {
th {
background-color: #fafafa;
font-weight: 500;
}
td {
vertical-align: top;
padding: 12px 16px;
}
}

View File

@ -0,0 +1,468 @@
/*
* 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.
*/
import { Component, OnInit, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NzModalService } from 'ng-zorro-antd/modal';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { SettingsService } from '@delon/theme';
import { Subject, takeUntil, finalize } from 'rxjs';
// 定义接口类型
interface PasskeyInfo {
id: string;
credentialId: string;
displayName: string;
deviceType: string;
signatureCount: number;
createdDate: string;
lastUsedDate?: string;
status: number; // 修复状态应为数字类型1表示活跃0表示禁用
}
interface ApiResponse<T = any> {
code: number;
message?: string;
data?: T;
}
interface UserInfo {
userId?: string;
id?: string;
username?: string;
displayName?: string;
}
@Component({
selector: 'app-passkey',
templateUrl: './passkey.component.html',
styleUrls: ['./passkey.component.less']
})
export class PasskeyComponent implements OnInit, OnDestroy {
loading = false;
passkeyList: PasskeyInfo[] = [];
private destroy$ = new Subject<void>();
constructor(
private msg: NzMessageService,
private modal: NzModalService,
private cdr: ChangeDetectorRef,
private http: HttpClient,
private settingsService: SettingsService
) {}
ngOnInit(): void {
this.loadPasskeys();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadPasskeys(): void {
const userId = this.getCurrentUserId();
if (!userId) {
this.msg.error('无法获取当前用户ID请重新登录');
return;
}
this.loading = true;
this.http.get<ApiResponse<PasskeyInfo[]>>(`/passkey/registration/list/${userId}`)
.pipe(
takeUntil(this.destroy$),
finalize(() => {
this.loading = false;
this.cdr.detectChanges();
})
)
.subscribe({
next: (response) => {
if (response.code === 0) {
this.passkeyList = response.data || [];
} else {
this.passkeyList = [];
this.msg.warning(response.message || '获取Passkey列表失败');
}
},
error: (error: HttpErrorResponse) => {
console.error('加载Passkey列表失败:', error);
this.passkeyList = [];
this.handleHttpError(error, '加载Passkey列表失败');
}
});
}
async registerPasskey(): Promise<void> {
console.log('=== PASSKEY REGISTRATION DEBUG START ===');
console.log('Passkey registration clicked at:', new Date().toISOString());
const userId = this.getCurrentUserId();
console.log('Current user ID:', userId);
if (!userId) {
console.error('No user ID available');
this.msg.error('无法获取当前用户ID请重新登录');
return;
}
// 检查浏览器是否支持 WebAuthn
if (!this.isWebAuthnSupported()) {
console.error('WebAuthn not supported');
this.msg.error('您的浏览器不支持 WebAuthn/Passkey 功能');
return;
}
console.log('WebAuthn support confirmed');
if (this.loading) {
console.log('Registration already in progress, ignoring click');
return; // 防止重复点击
}
try {
this.loading = true;
this.cdr.detectChanges();
const currentUser = this.settingsService.user as UserInfo;
console.log('Current user info:', {
userId: currentUser?.userId,
username: currentUser?.username,
displayName: currentUser?.displayName
});
// 调用后端 API 获取注册选项
console.log('Step 1: Requesting registration options from backend...');
const registrationRequest = {
userId: userId,
username: currentUser?.username || 'unknown_user',
displayName: currentUser?.displayName || '未知用户'
};
console.log('Registration request payload:', registrationRequest);
const beginResponse = await this.http.post<ApiResponse>('/passkey/registration/begin', registrationRequest).toPromise();
console.log('Backend registration options response:', beginResponse);
if (!beginResponse || beginResponse.code !== 0) {
console.error('Failed to get registration options:', beginResponse);
throw new Error(beginResponse?.message || '获取注册选项失败');
}
const regOptions = beginResponse.data;
console.log('Registration options received:', regOptions);
if (!regOptions) {
console.error('Empty registration options');
throw new Error('注册选项为空');
}
// 转换Base64字符串为ArrayBuffer
console.log('Step 2: Converting registration options...');
console.log('Original registration options:', {
challengeLength: regOptions.challenge?.length,
userIdLength: regOptions.user?.id?.length,
excludeCredentialsCount: regOptions.excludeCredentials?.length || 0,
timeout: regOptions.timeout,
rpId: regOptions.rp?.id,
rpName: regOptions.rp?.name
});
const convertedOptions = this.convertRegistrationOptions(regOptions);
console.log('Converted registration options:', {
challengeLength: convertedOptions.challenge.byteLength,
userIdLength: convertedOptions.user.id.byteLength,
userName: convertedOptions.user.name,
userDisplayName: convertedOptions.user.displayName,
excludeCredentialsCount: convertedOptions.excludeCredentials?.length || 0,
timeout: convertedOptions.timeout,
rpId: convertedOptions.rp.id,
rpName: convertedOptions.rp.name,
pubKeyCredParamsCount: convertedOptions.pubKeyCredParams?.length || 0
});
// 调用 WebAuthn API 进行注册
console.log('Step 3: Calling WebAuthn API navigator.credentials.create()...');
const credential = await navigator.credentials.create({
publicKey: convertedOptions
}) as PublicKeyCredential;
if (!credential) {
console.error('No credential returned from WebAuthn API');
throw new Error('凭证创建失败');
}
console.log('=== REGISTRATION CREDENTIAL DEBUG INFO ===');
console.log('Credential ID:', credential.id);
console.log('Credential ID length:', credential.id.length);
console.log('Credential type:', credential.type);
console.log('Credential rawId length:', credential.rawId.byteLength);
console.log('Credential rawId as base64:', this.arrayBufferToBase64(credential.rawId));
// 验证 credential.id 和 rawId 的一致性
const rawIdBase64 = this.arrayBufferToBase64(credential.rawId);
console.log('ID consistency check:');
console.log(' credential.id:', credential.id);
console.log(' rawId as base64:', rawIdBase64);
console.log(' IDs match:', credential.id === rawIdBase64);
const credentialResponse = credential.response as AuthenticatorAttestationResponse;
console.log('Authenticator response type:', credentialResponse.constructor.name);
console.log('Attestation object length:', credentialResponse.attestationObject.byteLength);
console.log('Client data JSON length:', credentialResponse.clientDataJSON.byteLength);
console.log('=== END REGISTRATION CREDENTIAL DEBUG INFO ===');
// 将注册结果发送到后端保存
console.log('Step 4: Sending registration result to backend...');
const finishRequest = {
userId: userId,
challengeId: regOptions.challengeId,
credentialId: credential.id,
attestationObject: this.arrayBufferToBase64(credentialResponse.attestationObject),
clientDataJSON: this.arrayBufferToBase64(credentialResponse.clientDataJSON)
};
console.log('Registration finish request payload:', {
userId: finishRequest.userId,
challengeId: finishRequest.challengeId,
credentialId: finishRequest.credentialId,
credentialIdLength: finishRequest.credentialId.length,
attestationObjectLength: finishRequest.attestationObject.length,
clientDataJSONLength: finishRequest.clientDataJSON.length
});
const finishResponse = await this.http.post<ApiResponse<PasskeyInfo>>('/passkey/registration/finish', finishRequest).toPromise();
console.log('Backend registration finish response:', finishResponse);
if (!finishResponse || finishResponse.code !== 0) {
console.error('Backend registration verification failed:', finishResponse);
throw new Error(finishResponse?.message || 'Passkey注册失败');
}
const passkeyInfo = finishResponse.data;
console.log('Registration successful, passkey info:', passkeyInfo);
if (passkeyInfo) {
this.msg.success(`Passkey 注册成功!`);
// 添加新注册的Passkey到列表中
console.log('Adding new passkey to list, current list length:', this.passkeyList.length);
this.passkeyList.unshift(passkeyInfo);
console.log('New list length:', this.passkeyList.length);
this.cdr.detectChanges();
console.log('=== PASSKEY REGISTRATION SUCCESS ===');
}
} catch (error: any) {
console.error('=== PASSKEY REGISTRATION ERROR ===');
console.error('Error type:', error.constructor.name);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
console.error('Full error object:', error);
// 如果是 WebAuthn 相关错误,提供更详细的信息
if (error.name) {
console.error('WebAuthn error name:', error.name);
switch (error.name) {
case 'NotAllowedError':
console.error('User cancelled the operation or timeout occurred');
break;
case 'SecurityError':
console.error('Security error - invalid domain or HTTPS required');
break;
case 'NotSupportedError':
console.error('Operation not supported by authenticator');
break;
case 'InvalidStateError':
console.error('Authenticator is in invalid state');
break;
case 'ConstraintError':
console.error('Constraint error in authenticator');
break;
case 'NotReadableError':
console.error('Authenticator data not readable');
break;
default:
console.error('Unknown WebAuthn error');
}
}
console.error('Passkey 注册失败:', error);
this.handlePasskeyError(error);
console.log('=== PASSKEY REGISTRATION DEBUG END ===');
} finally {
this.loading = false;
this.cdr.detectChanges();
console.log('Registration loading state reset');
}
}
deletePasskey(credentialId: string): void {
if (!credentialId) {
this.msg.error('凭证ID无效');
return;
}
const userId = this.getCurrentUserId();
if (!userId) {
this.msg.error('无法获取当前用户ID请重新登录');
return;
}
this.http.delete<ApiResponse>(`/passkey/registration/delete/${userId}/${credentialId}`)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
if (response.code === 0) {
this.msg.success('Passkey 删除成功');
// 从本地列表中移除,避免重新加载
this.passkeyList = this.passkeyList.filter(item => item.credentialId !== credentialId);
this.cdr.detectChanges();
} else {
this.msg.error(response.message || 'Passkey 删除失败');
}
},
error: (error: HttpErrorResponse) => {
console.error('删除Passkey失败:', error);
this.handleHttpError(error, 'Passkey 删除失败');
}
});
}
confirmDeletePasskey(credentialId: string): void {
this.modal.confirm({
nzTitle: '确认删除',
nzContent: '确定要删除这个 Passkey 吗?此操作不可撤销。',
nzOkText: '删除',
nzOkType: 'primary',
nzOkDanger: true,
nzCancelText: '取消',
nzOnOk: () => {
this.deletePasskey(credentialId);
}
});
}
/**
* ID
*/
private getCurrentUserId(): string | null {
const currentUser = this.settingsService.user as UserInfo;
return currentUser?.userId || currentUser?.id || null;
}
/**
* WebAuthn
*/
private isWebAuthnSupported(): boolean {
return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
}
/**
* Base64字符串为ArrayBuffer
*/
private convertRegistrationOptions(regOptions: any): PublicKeyCredentialCreationOptions {
return {
...regOptions,
challenge: this.base64ToArrayBuffer(regOptions.challenge),
user: {
...regOptions.user,
id: this.base64ToArrayBuffer(regOptions.user.id)
},
excludeCredentials: regOptions.excludeCredentials?.map((cred: any) => ({
...cred,
id: this.base64ToArrayBuffer(cred.id)
})) || []
};
}
/**
* Passkey相关错误
*/
private handlePasskeyError(error: any): void {
if (error.name === 'NotAllowedError') {
this.msg.error('Passkey 注册被取消或失败');
} else if (error.name === 'NotSupportedError') {
this.msg.error('您的设备不支持 Passkey 功能');
} else if (error.name === 'SecurityError') {
this.msg.error('安全错误请检查HTTPS连接');
} else if (error.name === 'InvalidStateError') {
this.msg.error('设备状态无效,请重试');
} else {
this.msg.error(error.message || 'Passkey 注册失败,请重试');
}
}
/**
* HTTP错误
*/
private handleHttpError(error: HttpErrorResponse, defaultMessage: string): void {
if (error.status === 401) {
this.msg.error('认证失败,请重新登录');
} else if (error.status === 403) {
this.msg.error('权限不足');
} else if (error.status === 404) {
this.msg.error('接口不存在');
} else if (error.status >= 500) {
this.msg.error('服务器错误,请稍后重试');
} else {
this.msg.error(defaultMessage);
}
}
/**
* Base64URL字符串转换为ArrayBuffer
*/
private base64ToArrayBuffer(base64: string): ArrayBuffer {
try {
// 将Base64URL转换为标准Base64
let normalizedBase64 = base64
.replace(/-/g, '+') // 替换 - 为 +
.replace(/_/g, '/'); // 替换 _ 为 /
// 添加必要的填充
const padded = normalizedBase64 + '='.repeat((4 - normalizedBase64.length % 4) % 4);
const binaryString = atob(padded);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
} catch (error) {
console.error('Base64解码失败:', error);
throw new Error('Base64解码失败');
}
}
/**
* ArrayBuffer转换为Base64URL字符串WebAuthn标准格式
*/
private arrayBufferToBase64(buffer: ArrayBuffer): string {
try {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
// 转换为标准Base64然后转换为Base64URL格式
return btoa(binary)
.replace(/\+/g, '-') // 替换 + 为 -
.replace(/\//g, '_') // 替换 / 为 _
.replace(/=/g, ''); // 移除填充字符 =
} catch (error) {
console.error('ArrayBuffer编码失败:', error);
throw new Error('ArrayBuffer编码失败');
}
}
}

View File

@ -101,6 +101,12 @@
{{ 'app.login.login' | i18n }}
</button>
</nz-form-item>
<nz-form-item *ngIf="loginType == 'normal'">
<button nz-button type="button" nzType="default" nzSize="large" (click)="passkeyLogin()" nzBlock>
<i nz-icon nzType="safety-certificate" nzTheme="outline"></i>
{{ 'mxk.login.passkey-login' | i18n }}
</button>
</nz-form-item>
</form>
<div class="other" *ngIf="loginType == 'normal'">
{{ 'app.login.sign-in-with' | i18n }}

View File

@ -77,7 +77,8 @@ export class UserLoginComponent implements OnInit, OnDestroy {
private reuseTabService: ReuseTabService,
private route: ActivatedRoute,
private msg: NzMessageService,
private cdr: ChangeDetectorRef
private cdr: ChangeDetectorRef,
private http: _HttpClient
) {
this.form = fb.group({
userName: [null, [Validators.required]],
@ -500,4 +501,281 @@ export class UserLoginComponent implements OnInit, OnDestroy {
this.cdr.detectChanges();
}, 2000);
}
/**
* Passkey
*/
async passkeyLogin(): Promise<void> {
console.log('=== PASSKEY LOGIN DEBUG START ===');
console.log('Passkey usernameless login clicked at:', new Date().toISOString());
try {
// 检查浏览器是否支持 WebAuthn
if (!window.PublicKeyCredential) {
console.error('WebAuthn not supported');
this.msg.error('您的浏览器不支持 WebAuthn/Passkey 功能');
return;
}
console.log('WebAuthn support confirmed');
this.loading = true;
this.cdr.detectChanges();
// 1. 调用后端 API 获取认证选项(不传递任何用户信息)
console.log('Step 1: Requesting authentication options from backend...');
let authOptionsResponse;
try {
authOptionsResponse = await this.http.post<any>('/passkey/authentication/begin?_allow_anonymous=true', {}).toPromise();
} catch (httpError: any) {
console.error('HTTP error occurred:', httpError);
// 处理HTTP错误提取错误信息
let errorMessage = '获取认证选项失败';
if (httpError.error && httpError.error.message) {
errorMessage = httpError.error.message;
} else if (httpError.message) {
errorMessage = httpError.message;
}
// 检查是否是没有注册 Passkey 的错误
if (errorMessage.includes('没有注册任何 Passkey') ||
errorMessage.includes('No Passkeys registered') ||
errorMessage.includes('还没有注册任何 Passkey') ||
errorMessage.includes('系统中还没有注册任何 Passkey')) {
// 直接显示友好提示并返回,不抛出错误避免被全局拦截器捕获
this.msg.warning('还未注册 Passkey请注册 Passkey');
console.log('=== PASSKEY LOGIN DEBUG END ===');
return;
}
throw new Error(errorMessage);
}
console.log('Backend auth options response:', authOptionsResponse);
if (!authOptionsResponse || authOptionsResponse.code !== 0) {
console.error('Failed to get auth options:', authOptionsResponse);
// 检查是否是没有注册 Passkey 的错误
const errorMessage = authOptionsResponse?.message || '获取认证选项失败';
if (errorMessage.includes('没有注册任何 Passkey') ||
errorMessage.includes('No Passkeys registered') ||
errorMessage.includes('还没有注册任何 Passkey') ||
errorMessage.includes('系统中还没有注册任何 Passkey')) {
// 直接显示友好提示并返回,不抛出错误避免被全局拦截器捕获
this.msg.warning('还未注册 Passkey请注册 Passkey');
console.log('=== PASSKEY LOGIN DEBUG END ===');
return;
}
throw new Error(errorMessage);
}
const authOptions = authOptionsResponse.data;
console.log('Auth options received:', authOptions);
// 检查返回的数据是否有效
if (!authOptions || !authOptions.challenge) {
console.error('Invalid auth options:', authOptions);
throw new Error('服务器返回的认证选项无效');
}
// 2. 转换认证选项格式
console.log('Step 2: Converting authentication options...');
const convertedOptions: PublicKeyCredentialRequestOptions = {
challenge: this.base64ToArrayBuffer(authOptions.challenge),
timeout: authOptions.timeout || 60000,
rpId: authOptions.rpId,
userVerification: authOptions.userVerification || 'preferred'
// 注意:不设置 allowCredentials让认证器自动选择可用的凭据
};
console.log('Converted options:', {
challengeLength: convertedOptions.challenge.byteLength,
timeout: convertedOptions.timeout,
rpId: convertedOptions.rpId,
userVerification: convertedOptions.userVerification,
allowCredentials: convertedOptions.allowCredentials || 'undefined (auto-select)'
});
// 3. 调用 WebAuthn API 进行认证
console.log('Step 3: Calling WebAuthn API navigator.credentials.get()...');
console.log('Available authenticators will be queried automatically');
const credential = await navigator.credentials.get({
publicKey: convertedOptions
}) as PublicKeyCredential;
if (!credential) {
console.error('No credential returned from WebAuthn API');
throw new Error('认证失败');
}
console.log('=== CREDENTIAL DEBUG INFO ===');
console.log('Credential ID:', credential.id);
console.log('Credential ID length:', credential.id.length);
console.log('Credential type:', credential.type);
console.log('Credential rawId length:', credential.rawId.byteLength);
console.log('Credential rawId as base64:', this.arrayBufferToBase64(credential.rawId));
// 验证 credential.id 和 rawId 的一致性
const rawIdBase64 = this.arrayBufferToBase64(credential.rawId);
console.log('ID consistency check:');
console.log(' credential.id:', credential.id);
console.log(' rawId as base64:', rawIdBase64);
console.log(' IDs match:', credential.id === rawIdBase64);
const credentialResponse = credential.response as AuthenticatorAssertionResponse;
console.log('Authenticator response type:', credentialResponse.constructor.name);
console.log('User handle:', credentialResponse.userHandle ? this.arrayBufferToBase64(credentialResponse.userHandle) : 'null');
console.log('=== END CREDENTIAL DEBUG INFO ===');
// 4. 将认证结果发送到后端验证
console.log('Step 4: Sending credential to backend for verification...');
const requestPayload = {
challengeId: authOptions.challengeId,
credentialId: credential.id,
authenticatorData: this.arrayBufferToBase64(credentialResponse.authenticatorData),
clientDataJSON: this.arrayBufferToBase64(credentialResponse.clientDataJSON),
signature: this.arrayBufferToBase64(credentialResponse.signature),
userHandle: credentialResponse.userHandle ? this.arrayBufferToBase64(credentialResponse.userHandle) : null
};
console.log('Request payload to backend:', {
challengeId: requestPayload.challengeId,
credentialId: requestPayload.credentialId,
credentialIdLength: requestPayload.credentialId.length,
authenticatorDataLength: requestPayload.authenticatorData.length,
clientDataJSONLength: requestPayload.clientDataJSON.length,
signatureLength: requestPayload.signature.length,
userHandle: requestPayload.userHandle
});
const finishResponse = await this.http.post<any>('/passkey/authentication/finish?_allow_anonymous=true', requestPayload).toPromise();
console.log('Backend finish response:', finishResponse);
if (!finishResponse || finishResponse.code !== 0) {
console.error('Backend verification failed:', finishResponse);
throw new Error(finishResponse?.message || 'Passkey认证失败');
}
// 5. 认证成功,设置用户信息并跳转
console.log('Step 5: Authentication successful, setting user info...');
const authResult = finishResponse.data;
console.log('Auth result received:', authResult);
this.msg.success(`Passkey 登录成功!欢迎 ${authResult.username || '用户'}`);
// 清空路由复用信息
console.log('Clearing reuse tab service...');
this.reuseTabService.clear();
// 设置用户Token信息
if (authResult && authResult.userId) {
console.log('Valid auth result with userId:', authResult.userId);
// 构建完整的认证信息对象,包含 SimpleGuard 所需的 token 和 ticket
const userInfo = {
id: authResult.userId,
userId: authResult.userId,
username: authResult.username,
displayName: authResult.displayName || authResult.username,
email: authResult.email || '',
authTime: authResult.authTime,
authType: 'passkey',
// 关键:包含认证所需的 token 和 ticket
token: authResult.token || authResult.congress || '',
ticket: authResult.ticket || authResult.onlineTicket || '',
// 其他可能需要的字段
remeberMe: false,
passwordSetType: authResult.passwordSetType || 'normal',
authorities: authResult.authorities || []
};
console.log('Setting auth info:', userInfo);
// 设置认证信息
this.authnService.auth(userInfo);
// 使用 navigate 方法进行跳转,它会处理 StartupService 的重新加载
console.log('Navigating with auth result...');
this.authnService.navigate(authResult);
console.log('=== PASSKEY LOGIN SUCCESS ===');
} else {
console.error('Invalid auth result - missing userId:', authResult);
throw new Error('认证成功但用户数据无效');
}
} catch (error: any) {
console.error('=== PASSKEY LOGIN ERROR ===');
console.error('Error type:', error.constructor.name);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
console.error('Full error object:', error);
// 检查是否是没有注册 Passkey 的错误
if (error.message && (error.message.includes('PASSKEY_NOT_REGISTERED') ||
error.message.includes('没有找到可用的凭据') ||
error.message.includes('No credentials available') ||
error.message.includes('用户未注册') ||
error.message.includes('credential not found') ||
error.message.includes('没有注册任何 Passkey') ||
error.message.includes('No Passkeys registered') ||
error.message.includes('还没有注册任何 Passkey'))) {
this.msg.warning('还未注册 Passkey请注册 Passkey');
console.log('=== PASSKEY LOGIN DEBUG END ===');
return;
}
// 如果是 WebAuthn 相关错误,提供更详细的信息
if (error.name) {
console.error('WebAuthn error name:', error.name);
switch (error.name) {
case 'NotAllowedError':
console.error('User cancelled the operation or timeout occurred');
// 检查是否是因为没有可用凭据导致的取消
this.msg.warning('Passkey 登录已取消。如果您还没有注册 Passkey请先注册后再使用');
break;
case 'SecurityError':
console.error('Security error - invalid domain or HTTPS required');
this.msg.error('安全错误:请确保在 HTTPS 环境下使用 Passkey 功能');
break;
case 'NotSupportedError':
console.error('Operation not supported by authenticator');
this.msg.error('您的设备不支持 Passkey 功能');
break;
case 'InvalidStateError':
console.error('Authenticator is in invalid state');
this.msg.error('认证器状态异常,请重试');
break;
case 'ConstraintError':
console.error('Constraint error in authenticator');
this.msg.error('认证器约束错误,请重试');
break;
default:
console.error('Unknown WebAuthn error');
this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式'));
}
} else {
this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式'));
}
console.log('=== PASSKEY LOGIN DEBUG END ===');
} finally {
this.loading = false;
this.cdr.detectChanges();
console.log('Login loading state reset');
}
}
// 添加辅助方法
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
}

View File

@ -43,6 +43,14 @@
"icon": "anticon-appstore",
"acl": "ROLE_USER",
"children": []
},
{
"text": "Passkey 注册",
"i18n": "mxk.menu.config.passkey",
"link": "/config/passkey",
"icon": "anticon-safety-certificate",
"acl": "ROLE_USER",
"children": []
},
{
"text": "二次认证",

View File

@ -16,6 +16,8 @@
"signup": "Sign up",
"login": "Login",
"twoFactor": "2-Factor Authentication",
"passkey-login": "Passkey Login",
"passkey-register": "Register Passkey",
"text.username": "Username",
"text.mobile": "Mobile Number",
"text.password": "Password",
@ -45,6 +47,7 @@
"": "Settings",
"setting": "Setting",
"profile": "Profile",
"passkey": "Passkey Registration",
"mfa": "MFA",
"password": "Password",
"socialsassociate": "Socials",

View File

@ -16,6 +16,8 @@
"signup": "用户注册",
"login": "登录",
"twoFactor": "二次身份认证",
"passkey-login": "Passkey登录",
"passkey-register": "注册Passkey",
"text.username": "用户名",
"text.mobile": "手机号码",
"text.password": "密码",
@ -45,6 +47,7 @@
"": "配置",
"setting": "基本设置",
"profile": "我的资料",
"passkey": "Passkey 注册",
"mfa": "二次认证",
"socialsassociate": "社交关联",
"password": "密码修改",

View File

@ -48,10 +48,10 @@ dependencies {
implementation project(":maxkey-starter:maxkey-starter-captcha")
implementation project(":maxkey-starter:maxkey-starter-ip2location")
implementation project(":maxkey-starter:maxkey-starter-otp")
implementation project(":maxkey-starter:maxkey-starter-passkey")
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")

View File

@ -29,3 +29,10 @@ spring.main.banner-mode =log
############################################################################
spring.profiles.active =${SERVER_PROFILES:maxkey}
############################################################################
# Passkey Configuration #
############################################################################
maxkey.passkey.enabled=true
maxkey.passkey.relying-party.name=MaxKey
maxkey.passkey.relying-party.id=localhost
maxkey.passkey.relying-party.allowed-origins=http://localhost:8527,http://localhost:8080