mirror of
https://gitee.com/dromara/MaxKey.git
synced 2025-12-06 08:59:10 +08:00
Compare commits
3 Commits
585a24d466
...
c02ae9bbc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02ae9bbc1 | ||
|
|
0733ce2d17 | ||
|
|
33734f1387 |
@ -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);
|
||||
|
||||
/**
|
||||
* 统计指定用户的挑战数量
|
||||
|
||||
@ -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记录
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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. 考虑添加更多的参数验证逻辑
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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编码失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }}
|
||||
|
||||
@ -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, '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "二次认证",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "密码修改",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user