feat: 实现 passkey 登录注册功能前端支持

- 添加 passkey 组件和相关路由配置
- 修复 build.gradle 添加 -parameters 编译参数
- 更新前端依赖和国际化配置
- 优化登录界面支持 passkey 认证
This commit is contained in:
Spock12138
2025-09-11 22:55:06 +08:00
parent 1317918fee
commit 33734f1387
14 changed files with 987 additions and 4 deletions

View File

@@ -54,6 +54,7 @@ allprojects {
targetCompatibility = 17
compileJava.options.encoding = 'UTF-8'
compileJava.options.compilerArgs += ['-parameters']
eclipse {
/*设置工程字符集*/
jdt {

View File

@@ -1,6 +1,6 @@
{
"name": "maxkey",
"version": "4.1.x",
"version": "4.1.0",
"description": "Leading-Edge IAM Identity and Access Management",
"author": "MaxKey <support@maxsso.net>",
"repository": {

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 [2022] [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

@@ -32,6 +32,26 @@
</div>
<div nz-col nzXs="24" nzSm="12" nzMd="6" class="mb-md">
<div nz-row nzType="flex" nzAlign="middle" class="bg-success rounded-md">
<div nz-col nzSpan="12" class="p-md text-white">
<div class="h2 mt0">{{ dayCount }}</div>
<p class="text-nowrap mb0">{{ 'mxk.home.dayCount' | i18n }}</p>
</div>
<div nz-col nzSpan="12">
<g2-mini-bar
*ngIf="simulateData"
height="35"
color="#fff"
borderWidth="3"
[padding]="[5, 30]"
[data]="simulateData"
tooltipType="mini"
(ready)="fixDark($event)"
></g2-mini-bar>
</div>
</div>
</div>
<div nz-col nzXs="24" nzSm="12" nzMd="6" class="mb-md">
<div nz-row nzType="flex" nzAlign="middle" class="bg-orange rounded-md">
<div nz-col nzSpan="12" class="p-md text-white">
<div class="h2 mt0">{{ newUsers }}</div>
<p class="text-nowrap mb0">{{ 'mxk.home.newUsers' | i18n }}</p>

View File

@@ -48,6 +48,7 @@ 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")
@@ -64,4 +65,11 @@ dependencies {
implementation project(":maxkey-protocols:maxkey-protocol-oauth-2.0")
implementation project(":maxkey-protocols:maxkey-protocol-saml-2.0")
implementation project(":maxkey-protocols:maxkey-protocol-jwt")
// WebAuthn4J 完整依赖
implementation "com.webauthn4j:webauthn4j-core:${webauthn4jVersion}"
implementation "com.webauthn4j:webauthn4j-util:${webauthn4jVersion}"
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor'
implementation 'commons-codec:commons-codec'
}

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